Annotator: side-by-side PDF view, paper upload with PMC XML
Browse files- Dockerfile +19 -0
- README.md +5 -4
- app/auth.py +68 -0
- app/main.py +666 -0
- app/requirements-convert.txt +2 -0
- app/requirements.txt +5 -0
- app/static/index.html +1215 -0
- app/store.py +397 -0
- app/textconv.py +529 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 云部署镜像(1003, Hugging Face Spaces / Render 通用): 只含代码, 不含 schema / 论文 / 标注 / 密钥。
|
| 2 |
+
# 论文和标注在 GitHub 私有仓库; schema 启动时从同一仓库读(SCHEMA_REPO_PATH); 密钥在平台的 Secrets 里。
|
| 3 |
+
# 含 Docling + PyMuPDF(CPU 版 torch), 供管理员「上传论文」在服务器上转换; 模型首次转换时下载到 HF_HOME。
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/* && useradd -m -u 1000 user
|
| 7 |
+
WORKDIR /srv
|
| 8 |
+
COPY app/requirements.txt app/requirements-convert.txt app/
|
| 9 |
+
RUN pip install --no-cache-dir -r app/requirements.txt \
|
| 10 |
+
&& pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
| 11 |
+
&& pip install --no-cache-dir -r app/requirements-convert.txt
|
| 12 |
+
COPY app app
|
| 13 |
+
# 上云默认开密码; 真要关可在平台变量里覆盖 REQUIRE_PASSWORD=0
|
| 14 |
+
ENV STORAGE_BACKEND=github PORT=7860 CACHE_DIR=/tmp/repl_cache REQUIRE_PASSWORD=1 \
|
| 15 |
+
SCHEMA_REPO_PATH=config/schema_0817.json HF_HOME=/tmp/hf OMP_NUM_THREADS=2 HOME=/home/user
|
| 16 |
+
USER user
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
WORKDIR /srv/app
|
| 19 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT} --proxy-headers --forwarded-allow-ips='*'"]
|
README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
---
|
| 2 |
title: Annotator
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
| 1 |
---
|
| 2 |
title: Annotator
|
| 3 |
+
emoji: 📝
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
Annotation tool (password protected). Papers, schema and annotations live in a private data repository; nothing is stored in this Space.
|
app/auth.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""登录: 标注人账号在环境变量 ANNOTATORS_JSON 里配置(密码只存 PBKDF2 哈希), 会话用 HMAC 签名 cookie。
|
| 2 |
+
|
| 3 |
+
ANNOTATORS_JSON 示例(scripts/hash_password.py 生成哈希):
|
| 4 |
+
{
|
| 5 |
+
"Rong": {"role": "admin", "pw": "pbkdf2_sha256$..."},
|
| 6 |
+
"A": {"pw": "pbkdf2_sha256$...", "papers": ["MICCAI_3979_paper"]},
|
| 7 |
+
"B": {"pw": "pbkdf2_sha256$..."}
|
| 8 |
+
}
|
| 9 |
+
papers 可选: 给了就只能看到这些论文。repo 可选: 给了就把此人标注写进那个仓库(默认写 DATA_REPO 的 annotations/<ID>/)。未配置 ANNOTATORS_JSON 且为 local 后端时 = 开放模式(同 0817 本地版, 任填 ID)。
|
| 10 |
+
"""
|
| 11 |
+
import base64
|
| 12 |
+
import hashlib
|
| 13 |
+
import hmac
|
| 14 |
+
import json
|
| 15 |
+
import secrets
|
| 16 |
+
import time
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
ITERATIONS = 200_000
|
| 20 |
+
COOKIE = "repl_sess"
|
| 21 |
+
SESSION_TTL = 14 * 24 * 3600
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def hash_password(pw: str, salt: Optional[str] = None) -> str:
|
| 25 |
+
salt = salt or secrets.token_hex(16)
|
| 26 |
+
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), ITERATIONS)
|
| 27 |
+
return f"pbkdf2_sha256${ITERATIONS}${salt}${dk.hex()}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def verify_password(pw: str, stored: str) -> bool:
|
| 31 |
+
try:
|
| 32 |
+
algo, it, salt, digest = stored.split("$")
|
| 33 |
+
except ValueError:
|
| 34 |
+
return False
|
| 35 |
+
if algo != "pbkdf2_sha256":
|
| 36 |
+
return False
|
| 37 |
+
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), int(it))
|
| 38 |
+
return hmac.compare_digest(dk.hex(), digest)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _b64(b: bytes) -> str:
|
| 42 |
+
return base64.urlsafe_b64encode(b).decode().rstrip("=")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _unb64(s: str) -> bytes:
|
| 46 |
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def sign_session(secret: str, annotator: str) -> str:
|
| 50 |
+
body = _b64(json.dumps({"a": annotator, "exp": int(time.time()) + SESSION_TTL}).encode())
|
| 51 |
+
mac = _b64(hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest())
|
| 52 |
+
return f"{body}.{mac}"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def read_session(secret: str, token: Optional[str]) -> Optional[str]:
|
| 56 |
+
if not token or "." not in token:
|
| 57 |
+
return None
|
| 58 |
+
body, mac = token.rsplit(".", 1)
|
| 59 |
+
want = _b64(hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest())
|
| 60 |
+
if not hmac.compare_digest(mac, want):
|
| 61 |
+
return None
|
| 62 |
+
try:
|
| 63 |
+
data = json.loads(_unb64(body))
|
| 64 |
+
except (ValueError, json.JSONDecodeError):
|
| 65 |
+
return None
|
| 66 |
+
if data.get("exp", 0) < time.time():
|
| 67 |
+
return None
|
| 68 |
+
return data.get("a")
|
app/main.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reproducibility 独立双标系统 — 后端(0925: 云部署 + GitHub 存储版)
|
| 2 |
+
|
| 3 |
+
本地跑: cd 03_annotation/app && pip install -r requirements.txt && python main.py
|
| 4 |
+
→ http://localhost:8420 (默认 local 后端 + 开放模式, 行为同 0817 本地版)
|
| 5 |
+
云部署: 见 ../README.md「云部署」一节(Dockerfile 在 03_annotation/)
|
| 6 |
+
|
| 7 |
+
设计要点:
|
| 8 |
+
- schema 驱动: 表单结构全部来自 ../schema/schema_0817.json, 改 schema 不改代码
|
| 9 |
+
- 盲标: 不加载任何 LLM 预填; 标注人只能读写自己的仓库, 看不到别人的; 每条标注盖 schema_version + annotator + 时间戳章
|
| 10 |
+
- grounding: evidence quote 保存时按 ground_check 同款算法(NFKC + 空白折叠)回填 char_span(折叠坐标)
|
| 11 |
+
- 存储: 见 store.py。一篇 × 一人一份 JSON, 内存即时生效, 后台定时 commit 到该标注人的私有仓库;
|
| 12 |
+
events 留全程操作痕迹, 每份标注记 papers_ref(论文快照 commit), 坐标系可追溯
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import secrets
|
| 18 |
+
import shutil
|
| 19 |
+
import subprocess
|
| 20 |
+
import sys
|
| 21 |
+
import tempfile
|
| 22 |
+
import threading
|
| 23 |
+
import time
|
| 24 |
+
import unicodedata
|
| 25 |
+
import uuid
|
| 26 |
+
from contextlib import asynccontextmanager
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
| 31 |
+
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
| 32 |
+
from pydantic import BaseModel
|
| 33 |
+
|
| 34 |
+
import httpx
|
| 35 |
+
|
| 36 |
+
import auth
|
| 37 |
+
from store import (DocCache, GitHubAnnotationStore, GitHubPapers, LocalAnnotationStore,
|
| 38 |
+
LocalPapers, env_flag)
|
| 39 |
+
|
| 40 |
+
HERE = Path(__file__).resolve().parent
|
| 41 |
+
BASE = HERE.parent # 03_annotation/
|
| 42 |
+
PROJECT = BASE.parent # 1.Reproducibility/
|
| 43 |
+
SCHEMA_PATH = Path(os.environ.get("SCHEMA_PATH", BASE / "schema" / "schema_0817.json"))
|
| 44 |
+
# 1003: 公开部署(HF Space)时 schema 不进镜像, 启动时从私有数据仓库读: SCHEMA_REPO_PATH=config/schema_0817.json
|
| 45 |
+
SCHEMA_REPO_PATH = os.environ.get("SCHEMA_REPO_PATH", "")
|
| 46 |
+
|
| 47 |
+
# ---------- 配置 ----------
|
| 48 |
+
|
| 49 |
+
BACKEND = os.environ.get("STORAGE_BACKEND", "local") # local | github
|
| 50 |
+
ANNOTATORS = json.loads(os.environ.get("ANNOTATORS_JSON", "") or "{}")
|
| 51 |
+
# 0925: 密码默认关(只填 ID 即可进), REQUIRE_PASSWORD=1 才校验 ANNOTATORS_JSON 里的 pw; Docker 镜像默认开。
|
| 52 |
+
# 配了 ANNOTATORS_JSON 时只允许名单里的 ID(防手误多出一个标注人); 没配(仅本地)则任填 ID。
|
| 53 |
+
REQUIRE_PASSWORD = env_flag("REQUIRE_PASSWORD", False)
|
| 54 |
+
AUTH = REQUIRE_PASSWORD and bool(ANNOTATORS)
|
| 55 |
+
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$") # ID 进存储路径, 只许安全字符
|
| 56 |
+
SESSION_SECRET = os.environ.get("SESSION_SECRET") or (secrets.token_hex(32) if not AUTH else "")
|
| 57 |
+
COOKIE_SECURE = env_flag("COOKIE_SECURE", BACKEND == "github")
|
| 58 |
+
FLUSH_SECONDS = float(os.environ.get("FLUSH_SECONDS", "15"))
|
| 59 |
+
|
| 60 |
+
if AUTH and not SESSION_SECRET:
|
| 61 |
+
raise SystemExit("SESSION_SECRET is required when REQUIRE_PASSWORD=1")
|
| 62 |
+
|
| 63 |
+
if BACKEND == "github":
|
| 64 |
+
# 默认一个仓库管所有: DATA_REPO 里 papers/ + annotations/<annotator>/;
|
| 65 |
+
# 某人配了 "repo" 则写进那个仓库的 annotations/(每人一仓库的旧方案, 仍支持)
|
| 66 |
+
TOKEN = os.environ["GITHUB_TOKEN"]
|
| 67 |
+
DATA_REPO = os.environ.get("DATA_REPO", "")
|
| 68 |
+
if not ANNOTATORS:
|
| 69 |
+
raise SystemExit("github backend needs ANNOTATORS_JSON")
|
| 70 |
+
locations = {}
|
| 71 |
+
for a, c in ANNOTATORS.items():
|
| 72 |
+
if c.get("repo"):
|
| 73 |
+
locations[a] = (c["repo"], "annotations")
|
| 74 |
+
elif DATA_REPO:
|
| 75 |
+
locations[a] = (DATA_REPO, f"annotations/{a}")
|
| 76 |
+
else:
|
| 77 |
+
raise SystemExit(f"annotator {a}: set DATA_REPO or give them a repo")
|
| 78 |
+
STORE = GitHubAnnotationStore(TOKEN, locations, branch=os.environ.get("DATA_BRANCH") or None)
|
| 79 |
+
PAPERS = GitHubPapers(TOKEN, os.environ.get("PAPERS_REPO") or DATA_REPO,
|
| 80 |
+
os.environ.get("DATA_BRANCH") or "main",
|
| 81 |
+
cache_dir=Path(os.environ.get("CACHE_DIR", "/tmp/repl_cache")),
|
| 82 |
+
subdir=os.environ.get("PAPERS_DIR", "papers"))
|
| 83 |
+
else:
|
| 84 |
+
STORE = LocalAnnotationStore(Path(os.environ.get("LOCAL_STORE_DIR", BASE / "data" / "store")))
|
| 85 |
+
PAPERS = LocalPapers(Path(os.environ.get("PAPERS_ROOT", PROJECT)),
|
| 86 |
+
Path(os.environ.get("PAPERS_MANIFEST", BASE / "papers" / "manifest.json")))
|
| 87 |
+
|
| 88 |
+
CACHE = DocCache(STORE, flush_seconds=FLUSH_SECONDS)
|
| 89 |
+
|
| 90 |
+
if SCHEMA_REPO_PATH and BACKEND == "github":
|
| 91 |
+
_r = httpx.get(f"https://api.github.com/repos/{os.environ.get('PAPERS_REPO') or DATA_REPO}/contents/{SCHEMA_REPO_PATH}",
|
| 92 |
+
params={"ref": os.environ.get("DATA_BRANCH") or "main"}, timeout=60,
|
| 93 |
+
headers={"Authorization": f"Bearer {TOKEN}", "Accept": "application/vnd.github.raw+json"})
|
| 94 |
+
_r.raise_for_status()
|
| 95 |
+
SCHEMA = _r.json()
|
| 96 |
+
else:
|
| 97 |
+
SCHEMA = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
| 98 |
+
SCHEMA_VERSION = SCHEMA["schema_version"]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@asynccontextmanager
|
| 102 |
+
async def lifespan(_app):
|
| 103 |
+
CACHE.start()
|
| 104 |
+
yield
|
| 105 |
+
CACHE.stop() # 关机前把没同步的改动写回
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
app = FastAPI(title="Reproducibility Annotator", lifespan=lifespan)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------- 会话 ----------
|
| 112 |
+
|
| 113 |
+
def is_admin(annotator: str) -> bool:
|
| 114 |
+
return ANNOTATORS.get(annotator, {}).get("role") == "admin"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def is_imported(annotator: str) -> bool:
|
| 118 |
+
"""role = imported: 导入的标注(不在本系统里标), 不能登录、不出现在登录页, 只供 admin 只读查看。
|
| 119 |
+
是什么来源不经任何接口暴露(盲审要求), 来源记录另存, 不在数据仓库。"""
|
| 120 |
+
return ANNOTATORS.get(annotator, {}).get("role") == "imported"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
LOGIN_IDS = [a for a in ANNOTATORS if not is_imported(a)]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def current_annotator(request: Request) -> str:
|
| 127 |
+
a = auth.read_session(SESSION_SECRET, request.cookies.get(auth.COOKIE))
|
| 128 |
+
if not a or (ANNOTATORS and a not in ANNOTATORS) or is_imported(a):
|
| 129 |
+
raise HTTPException(401, "not logged in")
|
| 130 |
+
return a
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def allowed_papers(annotator: str) -> dict:
|
| 134 |
+
"""admin 看全部; 配了 papers 的人只看那些; 其余人看仓库里的论文池 papers/pool.json(1003, 大家同一个池子)。"""
|
| 135 |
+
papers = PAPERS.manifest()["papers"]
|
| 136 |
+
if is_admin(annotator):
|
| 137 |
+
return papers
|
| 138 |
+
only = ANNOTATORS.get(annotator, {}).get("papers")
|
| 139 |
+
if only is None:
|
| 140 |
+
only = PAPERS.pool()
|
| 141 |
+
if only is not None:
|
| 142 |
+
papers = {k: v for k, v in papers.items() if k in set(only)}
|
| 143 |
+
return papers
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def paper_entry(annotator: str, pid: str) -> dict:
|
| 147 |
+
papers = allowed_papers(annotator)
|
| 148 |
+
if pid not in papers:
|
| 149 |
+
raise HTTPException(404, f"unknown paper {pid}")
|
| 150 |
+
return papers[pid]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------- 标注文档 ----------
|
| 154 |
+
|
| 155 |
+
def load_doc(annotator: str, paper: str) -> dict:
|
| 156 |
+
doc = CACHE.get(annotator, paper, PAPERS.ref)
|
| 157 |
+
with CACHE.lock:
|
| 158 |
+
seen = doc.setdefault("papers_refs_seen", [doc.get("papers_ref")])
|
| 159 |
+
if PAPERS.ref not in seen:
|
| 160 |
+
# 论文快照换了: char_span 坐标系可能变, 记一笔, 仲裁 / 导出时可查
|
| 161 |
+
seen.append(PAPERS.ref)
|
| 162 |
+
doc["events"].append({"ts": time.time(), "kind": "papers_ref_changed", "detail": PAPERS.ref})
|
| 163 |
+
CACHE.mark_dirty(annotator, paper, f"papers_ref_changed {PAPERS.ref[:12]}")
|
| 164 |
+
return doc
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def log_event(doc: dict, annotator: str, paper: str, kind: str, detail: str = ""):
|
| 168 |
+
doc["events"].append({"ts": time.time(), "kind": kind, "detail": detail[:500]})
|
| 169 |
+
CACHE.mark_dirty(annotator, paper, f"{kind} {detail[:80]}")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------- grounding (ground_check 同款) ----------
|
| 173 |
+
|
| 174 |
+
def fold(t: str) -> str:
|
| 175 |
+
t = unicodedata.normalize("NFKC", t)
|
| 176 |
+
return re.sub(r"\s+", " ", t).strip()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
_FOLDED = {}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def folded_fulltext(annotator: str, pid: str) -> str:
|
| 183 |
+
rel = paper_entry(annotator, pid)["fulltext"]
|
| 184 |
+
if rel not in _FOLDED:
|
| 185 |
+
_FOLDED[rel] = fold(PAPERS.text(rel))
|
| 186 |
+
return _FOLDED[rel]
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def relaxed_find(text: str, q: str):
|
| 190 |
+
"""markdown 容忍匹配: 渲染视图选出的 quote 不含标记符(** ` | #), 源文件里有。
|
| 191 |
+
在 quote 每个字符间允许穿插标记符, 空格处允许穿插空白+标记符, 返回源文件折叠坐标。"""
|
| 192 |
+
parts = []
|
| 193 |
+
for ch in q:
|
| 194 |
+
if ch == " ":
|
| 195 |
+
parts.append(r"[\s|*_`#]+")
|
| 196 |
+
else:
|
| 197 |
+
parts.append(re.escape(ch) + r"[|*_`]*")
|
| 198 |
+
m = re.compile("".join(parts)).search(text)
|
| 199 |
+
return (m.start(), m.end()) if m else None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
_LAYOUT = {}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def fold_with_map(raw: str):
|
| 206 |
+
"""逐字符版 fold, 同时记每个折叠字符对应的原文下标。只用来把 quote 落到 layout 块上;
|
| 207 |
+
char_span 仍按 fold() 整串口径算, 两者在组合字符处可能差一两位, 不影响定位到哪一块。"""
|
| 208 |
+
out, idx = [], []
|
| 209 |
+
for i, ch in enumerate(raw):
|
| 210 |
+
n = unicodedata.normalize("NFKC", ch)
|
| 211 |
+
if not n.strip():
|
| 212 |
+
if out and out[-1] != " ":
|
| 213 |
+
out.append(" ")
|
| 214 |
+
idx.append(i)
|
| 215 |
+
continue
|
| 216 |
+
for c in n:
|
| 217 |
+
out.append(c)
|
| 218 |
+
idx.append(i)
|
| 219 |
+
if out and out[-1] == " ":
|
| 220 |
+
out.pop()
|
| 221 |
+
idx.pop()
|
| 222 |
+
return "".join(out), idx
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def paper_layout(annotator: str, pid: str):
|
| 226 |
+
"""layout.json(1003 起): 人读 md 的每块 → PDF (page, bbox)。没有 layout 的论文(旧 txt / 0817 md)返回 None。"""
|
| 227 |
+
e = paper_entry(annotator, pid)
|
| 228 |
+
rel = e.get("layout")
|
| 229 |
+
if not rel:
|
| 230 |
+
return None
|
| 231 |
+
if rel not in _LAYOUT:
|
| 232 |
+
lay = json.loads(PAPERS.text(rel))
|
| 233 |
+
folded, idx = fold_with_map(PAPERS.text(e["fulltext"]))
|
| 234 |
+
_LAYOUT[rel] = {"raw": lay, "folded": folded, "idx": idx}
|
| 235 |
+
return _LAYOUT[rel]
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def pdf_anchor_for(lay: dict, quote: str):
|
| 239 |
+
"""quote → 覆盖它的 layout 块 → 这些块在 PDF 上的框。找不到返回 None。"""
|
| 240 |
+
text, q = lay["folded"], fold(quote)
|
| 241 |
+
i = text.find(q)
|
| 242 |
+
sp = (i, i + len(q)) if i >= 0 else relaxed_find(text, q)
|
| 243 |
+
if not sp or sp[1] <= sp[0]:
|
| 244 |
+
return None
|
| 245 |
+
a, b = lay["idx"][sp[0]], lay["idx"][sp[1] - 1] + 1
|
| 246 |
+
blocks = [blk for blk in lay["raw"]["blocks"] if blk["start"] < b and blk["end"] > a]
|
| 247 |
+
boxes = [bx for blk in blocks for bx in blk["boxes"]]
|
| 248 |
+
return {"blocks": [blk["id"] for blk in blocks], "boxes": boxes} if blocks else None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def ground_evidence(annotator: str, pid: str, payload: dict) -> dict:
|
| 252 |
+
"""对 payload.evidence 里每条 quote 回填折叠坐标 char_span; 找不到标 grounded=false 并降级提示。
|
| 253 |
+
两层: ① NFKC+折叠后精确子串(与 ground_check 同口径) ② markdown 容忍匹配(md 语料, 0817 加)。
|
| 254 |
+
有 layout 的论文另回填 pdf_anchor(块 id + PDF 框); 在 PDF 上直接划选的证据(origin=pdf)
|
| 255 |
+
保留前端记下的精确框, 即使文本里对不上(转换损坏)也不丢位置。"""
|
| 256 |
+
text = folded_fulltext(annotator, pid)
|
| 257 |
+
lay = paper_layout(annotator, pid)
|
| 258 |
+
for ev in payload.get("evidence", []):
|
| 259 |
+
q = fold(ev.get("quote", ""))
|
| 260 |
+
if lay and q and ev.get("origin") != "pdf":
|
| 261 |
+
ev["pdf_anchor"] = pdf_anchor_for(lay, q)
|
| 262 |
+
if not q:
|
| 263 |
+
ev["grounded"] = False
|
| 264 |
+
ev["char_span"] = None
|
| 265 |
+
continue
|
| 266 |
+
i = text.find(q)
|
| 267 |
+
if i >= 0:
|
| 268 |
+
ev["char_span"] = [i, i + len(q)]
|
| 269 |
+
ev["grounded"] = True
|
| 270 |
+
ev.pop("match", None)
|
| 271 |
+
else:
|
| 272 |
+
sp = relaxed_find(text, q)
|
| 273 |
+
if sp:
|
| 274 |
+
ev["char_span"] = [sp[0], sp[1]]
|
| 275 |
+
ev["grounded"] = True
|
| 276 |
+
ev["match"] = "relaxed" # 命中含 markdown 标记的源文本
|
| 277 |
+
else:
|
| 278 |
+
ev["char_span"] = None
|
| 279 |
+
ev["grounded"] = False # 失配降级, 前端提示核对
|
| 280 |
+
return payload
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# ---------- API: 登录 ----------
|
| 284 |
+
|
| 285 |
+
@app.get("/", response_class=HTMLResponse)
|
| 286 |
+
def index():
|
| 287 |
+
return (HERE / "static" / "index.html").read_text(encoding="utf-8")
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@app.get("/api/config")
|
| 291 |
+
def get_config():
|
| 292 |
+
return {"auth": AUTH, "backend": BACKEND, "schema_version": SCHEMA_VERSION,
|
| 293 |
+
"annotators": [] if AUTH else LOGIN_IDS}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class Login(BaseModel):
|
| 297 |
+
annotator: str
|
| 298 |
+
password: str = ""
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@app.post("/api/login")
|
| 302 |
+
def login(body: Login, response: Response):
|
| 303 |
+
a = body.annotator.strip()
|
| 304 |
+
if not ID_RE.match(a):
|
| 305 |
+
raise HTTPException(400, "annotator ID: letters, digits, _ or - only")
|
| 306 |
+
if ANNOTATORS and a not in ANNOTATORS:
|
| 307 |
+
raise HTTPException(401, "unknown annotator ID")
|
| 308 |
+
if is_imported(a):
|
| 309 |
+
raise HTTPException(403, "imported annotator — cannot sign in")
|
| 310 |
+
if AUTH and not auth.verify_password(body.password, ANNOTATORS[a].get("pw", "")):
|
| 311 |
+
time.sleep(1.0) # 放慢暴力尝试
|
| 312 |
+
raise HTTPException(401, "wrong annotator ID or password")
|
| 313 |
+
response.set_cookie(auth.COOKIE, auth.sign_session(SESSION_SECRET, a), max_age=auth.SESSION_TTL,
|
| 314 |
+
httponly=True, samesite="lax", secure=COOKIE_SECURE)
|
| 315 |
+
return {"annotator": a, "admin": is_admin(a)}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@app.post("/api/logout")
|
| 319 |
+
def logout(response: Response):
|
| 320 |
+
response.delete_cookie(auth.COOKIE)
|
| 321 |
+
return {"ok": True}
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@app.get("/api/me")
|
| 325 |
+
def me(annotator: str = Depends(current_annotator)):
|
| 326 |
+
out = {"annotator": annotator, "admin": is_admin(annotator)}
|
| 327 |
+
if out["admin"]:
|
| 328 |
+
out["annotators"] = list(ANNOTATORS)
|
| 329 |
+
return out
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ---------- API: schema / 论文 ----------
|
| 333 |
+
|
| 334 |
+
@app.get("/api/schema")
|
| 335 |
+
def get_schema(_a: str = Depends(current_annotator)):
|
| 336 |
+
return SCHEMA
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def clean_title(pid: str, entry: dict) -> str:
|
| 340 |
+
"""manifest 里旧的 title 是全文首行, MICCAI 是 "<!-- image -->"、openreview 有页码; publish_papers 新版已写干净的。"""
|
| 341 |
+
t = (entry.get("title") or "").strip()
|
| 342 |
+
t = re.sub(r"^#+\s*", "", t)
|
| 343 |
+
if not t or t.startswith("<!--") or t.isdigit():
|
| 344 |
+
return pid
|
| 345 |
+
return t
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
@app.get("/api/papers")
|
| 349 |
+
def get_papers(annotator: str = Depends(current_annotator)):
|
| 350 |
+
papers = {pid: {**e, "title": clean_title(pid, e)} for pid, e in allowed_papers(annotator).items()}
|
| 351 |
+
out = {"papers": papers, "papers_ref": PAPERS.ref}
|
| 352 |
+
if is_admin(annotator):
|
| 353 |
+
out["pool"] = PAPERS.pool()
|
| 354 |
+
return out
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def filled(payload: dict) -> bool:
|
| 358 |
+
# 外键 / 轴 / no_anchor 也算填了(F2、M7、D24、R3、D27 常常只有这些)
|
| 359 |
+
return bool(payload.get("status") or payload.get("value") or payload.get("evidence")
|
| 360 |
+
or payload.get("fk") or payload.get("axes") or payload.get("no_anchor"))
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@app.get("/api/overview")
|
| 364 |
+
def overview(annotator: Optional[str] = None, me_: str = Depends(current_annotator)):
|
| 365 |
+
"""首页卡片用: 每篇论文 × 当前标注人(admin 可指定别人)按记录类型的记录数 / 已填字段数。
|
| 366 |
+
只加载存储里已有的文档(一次 list), 没标过的论文不打 GitHub。"""
|
| 367 |
+
who = annotator if (annotator and is_admin(me_)) else me_
|
| 368 |
+
existing = {Path(n).stem for n in STORE.list(who)}
|
| 369 |
+
with CACHE.lock:
|
| 370 |
+
existing |= {p for (a, p) in CACHE.docs if a == who}
|
| 371 |
+
out = {}
|
| 372 |
+
for pid in allowed_papers(me_):
|
| 373 |
+
blocks, total, last = {}, 0, None
|
| 374 |
+
if pid in existing:
|
| 375 |
+
doc = load_doc(who, pid)
|
| 376 |
+
with CACHE.lock:
|
| 377 |
+
for uid, r in doc["records"].items():
|
| 378 |
+
if r.get("deleted"):
|
| 379 |
+
continue
|
| 380 |
+
n = sum(1 for f in doc["fields"].get(uid, {}).values() if filled(f["payload"]))
|
| 381 |
+
b = blocks.setdefault(r["rtype"], {"records": 0, "filled": 0})
|
| 382 |
+
b["records"] += 1
|
| 383 |
+
b["filled"] += n
|
| 384 |
+
total += n
|
| 385 |
+
last = max((e["ts"] for e in doc["events"]), default=None)
|
| 386 |
+
out[pid] = {"blocks": blocks, "filled": total, "last_ts": last}
|
| 387 |
+
return {"annotator": who, "papers": out}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@app.get("/api/paper/{pid}/text", response_class=PlainTextResponse)
|
| 391 |
+
def get_text(pid: str, annotator: str = Depends(current_annotator)):
|
| 392 |
+
return PAPERS.text(paper_entry(annotator, pid)["fulltext"])
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@app.get("/api/paper/{pid}/layout")
|
| 396 |
+
def get_layout(pid: str, annotator: str = Depends(current_annotator)):
|
| 397 |
+
lay = paper_layout(annotator, pid)
|
| 398 |
+
if not lay:
|
| 399 |
+
raise HTTPException(404, "no layout for this paper")
|
| 400 |
+
return lay["raw"]
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@app.get("/api/paper/{pid}/pdf")
|
| 404 |
+
def get_pdf(pid: str, annotator: str = Depends(current_annotator)):
|
| 405 |
+
entry = paper_entry(annotator, pid)
|
| 406 |
+
if not entry.get("pdf"):
|
| 407 |
+
raise HTTPException(404, "no pdf for this paper")
|
| 408 |
+
return FileResponse(PAPERS.pdf_path(entry["pdf"]), media_type="application/pdf")
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
# ---------- API: 标注 ----------
|
| 412 |
+
|
| 413 |
+
@app.get("/api/state")
|
| 414 |
+
def get_state(paper: str, annotator: Optional[str] = None, me_: str = Depends(current_annotator)):
|
| 415 |
+
# 标注人只能读自己的; admin 可以只读查看别人的(仲裁用)
|
| 416 |
+
who = annotator if (annotator and is_admin(me_)) else me_
|
| 417 |
+
paper_entry(me_, paper)
|
| 418 |
+
doc = load_doc(who, paper)
|
| 419 |
+
with CACHE.lock:
|
| 420 |
+
recs = sorted(({"uid": uid, "rtype": r["rtype"], "extra": r.get("extra", {}),
|
| 421 |
+
"created_ts": r["created_ts"]}
|
| 422 |
+
for uid, r in doc["records"].items() if not r.get("deleted")),
|
| 423 |
+
key=lambda r: r["created_ts"])
|
| 424 |
+
flds = {uid: {fid: f["payload"] for fid, f in m.items()} for uid, m in doc["fields"].items()}
|
| 425 |
+
return {"records": recs, "fields": flds, "schema_version": SCHEMA_VERSION,
|
| 426 |
+
"annotator": who, "read_only": who != me_}
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
class RecordOp(BaseModel):
|
| 430 |
+
paper: str
|
| 431 |
+
action: str # create / delete / extra
|
| 432 |
+
annotator: Optional[str] = None # 旧前端兼容, 忽略; 身份以会话为准
|
| 433 |
+
rtype: Optional[str] = None
|
| 434 |
+
uid: Optional[str] = None
|
| 435 |
+
extra: Optional[dict] = None
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
@app.post("/api/record")
|
| 439 |
+
def record_op(op: RecordOp, annotator: str = Depends(current_annotator)):
|
| 440 |
+
paper_entry(annotator, op.paper)
|
| 441 |
+
doc = load_doc(annotator, op.paper)
|
| 442 |
+
now = time.time()
|
| 443 |
+
with CACHE.lock:
|
| 444 |
+
if op.action == "create":
|
| 445 |
+
uid = str(uuid.uuid4())[:8]
|
| 446 |
+
doc["records"][uid] = {"rtype": op.rtype, "extra": op.extra or {}, "deleted": 0,
|
| 447 |
+
"created_ts": now, "updated_ts": now}
|
| 448 |
+
log_event(doc, annotator, op.paper, "record_create", f"{op.rtype}:{uid}")
|
| 449 |
+
return {"uid": uid}
|
| 450 |
+
rec = doc["records"].get(op.uid or "")
|
| 451 |
+
if rec is None:
|
| 452 |
+
raise HTTPException(404, "unknown record")
|
| 453 |
+
if op.action == "delete":
|
| 454 |
+
rec["deleted"] = 1
|
| 455 |
+
rec["updated_ts"] = now
|
| 456 |
+
log_event(doc, annotator, op.paper, "record_delete", op.uid)
|
| 457 |
+
return {"ok": True}
|
| 458 |
+
if op.action == "extra":
|
| 459 |
+
rec["extra"] = op.extra or {}
|
| 460 |
+
rec["updated_ts"] = now
|
| 461 |
+
log_event(doc, annotator, op.paper, "record_extra", op.uid)
|
| 462 |
+
return {"ok": True}
|
| 463 |
+
raise HTTPException(400, "bad action")
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
class FieldSave(BaseModel):
|
| 467 |
+
paper: str
|
| 468 |
+
record_uid: str
|
| 469 |
+
field_id: str
|
| 470 |
+
payload: dict
|
| 471 |
+
annotator: Optional[str] = None # 同上, 忽略
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
@app.post("/api/field")
|
| 475 |
+
def field_save(s: FieldSave, annotator: str = Depends(current_annotator)):
|
| 476 |
+
payload = ground_evidence(annotator, s.paper, s.payload)
|
| 477 |
+
doc = load_doc(annotator, s.paper)
|
| 478 |
+
with CACHE.lock:
|
| 479 |
+
if s.record_uid not in doc["records"]:
|
| 480 |
+
raise HTTPException(404, "unknown record")
|
| 481 |
+
doc["fields"].setdefault(s.record_uid, {})[s.field_id] = {
|
| 482 |
+
"payload": payload, "schema_version": SCHEMA_VERSION, "updated_ts": time.time()}
|
| 483 |
+
log_event(doc, annotator, s.paper, "field_save", f"{s.record_uid}.{s.field_id}")
|
| 484 |
+
return {"payload": payload}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
@app.get("/api/progress")
|
| 488 |
+
def progress(paper: str, annotator: str = Depends(current_annotator)):
|
| 489 |
+
doc = load_doc(annotator, paper)
|
| 490 |
+
with CACHE.lock:
|
| 491 |
+
n = sum(len(m) for m in doc["fields"].values())
|
| 492 |
+
return {"fields_touched": n}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@app.get("/api/sync")
|
| 496 |
+
def sync_status(annotator: str = Depends(current_annotator)):
|
| 497 |
+
return {**CACHE.status(annotator), "backend": BACKEND}
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
@app.post("/api/sync")
|
| 501 |
+
def sync_now(annotator: str = Depends(current_annotator)):
|
| 502 |
+
CACHE.flush()
|
| 503 |
+
return {**CACHE.status(annotator), "backend": BACKEND}
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
# ---------- API: admin ----------
|
| 507 |
+
|
| 508 |
+
@app.get("/api/admin/export")
|
| 509 |
+
def admin_export(annotator: str = Depends(current_annotator)):
|
| 510 |
+
"""全部标注人 × 全部论文的原始文档(先把内存里的改动写回, 再从存储读)。"""
|
| 511 |
+
if not is_admin(annotator):
|
| 512 |
+
raise HTTPException(403, "admin only")
|
| 513 |
+
CACHE.flush()
|
| 514 |
+
people = list(ANNOTATORS) if ANNOTATORS else sorted(
|
| 515 |
+
p.name for p in getattr(STORE, "root", Path("/nonexistent")).glob("*") if p.is_dir())
|
| 516 |
+
out = {}
|
| 517 |
+
for a in people:
|
| 518 |
+
for name in STORE.list(a):
|
| 519 |
+
data, _ = STORE.read(a, name)
|
| 520 |
+
if data:
|
| 521 |
+
out.setdefault(a, {})[Path(name).stem] = json.loads(data)
|
| 522 |
+
return {"exported": time.time(), "papers_ref": PAPERS.ref, "schema_version": SCHEMA_VERSION,
|
| 523 |
+
"annotations": out}
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ---------- API: admin 上传论文(1003) ----------
|
| 527 |
+
# 上传 PDF 和 / 或填 PMCID / DOI / PMID → 子进程跑 textconv.py(PMC 开放获取有 XML 就用 XML, 否则 Docling)
|
| 528 |
+
# → 一个 commit 写进 papers/<pid>/(只增不改) → 可选加入论文池。一次只转一篇(Docling 吃内存)。
|
| 529 |
+
|
| 530 |
+
CONVERT_PYTHON = os.environ.get("CONVERT_PYTHON", sys.executable)
|
| 531 |
+
MAX_UPLOAD_MB = float(os.environ.get("MAX_UPLOAD_MB", "60"))
|
| 532 |
+
JOBS: dict = {}
|
| 533 |
+
_CONVERT_LOCK = threading.Lock()
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def _abstract(md: str) -> str:
|
| 537 |
+
paras = [p.strip() for p in md.split("\n\n") if p.strip()]
|
| 538 |
+
for i, p in enumerate(paras):
|
| 539 |
+
if re.match(r"^#+\s*abstract\b", p, re.I) and i + 1 < len(paras):
|
| 540 |
+
a = paras[i + 1]
|
| 541 |
+
break
|
| 542 |
+
m = re.match(r"^abstract[.:\s]+(.{80,})", p, re.I | re.S)
|
| 543 |
+
if m:
|
| 544 |
+
a = m.group(1)
|
| 545 |
+
break
|
| 546 |
+
else:
|
| 547 |
+
a = next((p for p in paras if not p.startswith(("#", "|", "[")) and len(p) > 200), "")
|
| 548 |
+
a = re.sub(r"\s+", " ", a)
|
| 549 |
+
return a[:400].rsplit(" ", 1)[0] + " …" if len(a) > 400 else a
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def _new_pid(meta: dict, filename: str) -> str:
|
| 553 |
+
if meta.get("pmcid"):
|
| 554 |
+
return f"PMC_{meta['pmcid']}"
|
| 555 |
+
stem = re.sub(r"[^A-Za-z0-9]+", "-", Path(filename or "paper").stem).strip("-")[:40] or "paper"
|
| 556 |
+
pid, n, have = f"UP_{stem}", 2, PAPERS.manifest()["papers"]
|
| 557 |
+
while pid in have:
|
| 558 |
+
pid, n = f"UP_{stem}-{n}", n + 1
|
| 559 |
+
return pid
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def _run_upload(jid: str, work: Path, pdf: Optional[Path], ident: str, title: str, venue: str,
|
| 563 |
+
to_pool: bool, who: str, filename: str):
|
| 564 |
+
job = JOBS[jid]
|
| 565 |
+
try:
|
| 566 |
+
with _CONVERT_LOCK:
|
| 567 |
+
job["status"] = "running"
|
| 568 |
+
out = work / "out"
|
| 569 |
+
cmd = [CONVERT_PYTHON, str(HERE / "textconv.py"), "job", "--out", str(out)]
|
| 570 |
+
cmd += ["--pdf", str(pdf)] if pdf else []
|
| 571 |
+
cmd += ["--ident", ident] if ident else []
|
| 572 |
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
| 573 |
+
for line in proc.stdout:
|
| 574 |
+
line = line.rstrip()
|
| 575 |
+
if line and not re.search(r"warn|it/s|^\s|^\[|INFO|DEBUG|torch|conv2d", line, re.I):
|
| 576 |
+
job["log"].append(line[:300])
|
| 577 |
+
if proc.wait() != 0 or not (out / "meta.json").exists():
|
| 578 |
+
err = next((l for l in reversed(job["log"]) if l.startswith("ERROR")), "conversion failed")
|
| 579 |
+
raise RuntimeError(err.replace("ERROR: ", ""))
|
| 580 |
+
meta = json.loads((out / "meta.json").read_text(encoding="utf-8"))
|
| 581 |
+
pid = _new_pid(meta, filename)
|
| 582 |
+
if pid in PAPERS.manifest()["papers"]:
|
| 583 |
+
raise RuntimeError(f"{pid} is already in the system")
|
| 584 |
+
md = (out / "fulltext.md").read_text(encoding="utf-8")
|
| 585 |
+
files = {"fulltext.md": md.encode("utf-8"), "layout.json": (out / "layout.json").read_bytes(),
|
| 586 |
+
"paper.pdf": (out / "paper.pdf").read_bytes()}
|
| 587 |
+
entry = {"fulltext": "fulltext.md", "pdf": "paper.pdf", "layout": "layout.json",
|
| 588 |
+
"venue": venue or ("PMC" if meta.get("pmcid") else "Upload"),
|
| 589 |
+
"title": title or meta.get("title") or Path(filename or pid).stem,
|
| 590 |
+
"abstract": _abstract(md), "text_source": meta["source"],
|
| 591 |
+
"pmcid": meta.get("pmcid"), "doi": meta.get("doi"), "license": meta.get("license"),
|
| 592 |
+
"uploaded_by": who, "uploaded_ts": time.time(), "upload_name": filename or None}
|
| 593 |
+
job["log"].append("saving to the data repository…")
|
| 594 |
+
PAPERS.add_paper(pid, files, entry, message=f"papers: add {pid} ({meta['source']}, uploaded by {who})")
|
| 595 |
+
if to_pool:
|
| 596 |
+
pool = PAPERS.pool() or []
|
| 597 |
+
if pid not in pool:
|
| 598 |
+
PAPERS.set_pool(pool + [pid], message=f"papers: add {pid} to the annotator pool")
|
| 599 |
+
job.update(status="done", pid=pid, title=entry["title"], source=meta["source"])
|
| 600 |
+
job["log"].append(f"done → {pid}" + (" (added to the pool)" if to_pool else ""))
|
| 601 |
+
except Exception as e: # noqa: BLE001
|
| 602 |
+
job.update(status="error", error=str(e))
|
| 603 |
+
finally:
|
| 604 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def require_admin(annotator: str = Depends(current_annotator)) -> str:
|
| 608 |
+
if not is_admin(annotator):
|
| 609 |
+
raise HTTPException(403, "admin only")
|
| 610 |
+
return annotator
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
@app.post("/api/admin/upload")
|
| 614 |
+
async def admin_upload(pdf: Optional[UploadFile] = File(None), ident: str = Form(""), title: str = Form(""),
|
| 615 |
+
venue: str = Form(""), add_to_pool: bool = Form(True), who: str = Depends(require_admin)):
|
| 616 |
+
ident = ident.strip()
|
| 617 |
+
if not pdf and not ident:
|
| 618 |
+
raise HTTPException(400, "upload a PDF or give a PMCID / DOI / PMID")
|
| 619 |
+
work = Path(tempfile.mkdtemp(prefix="upload_"))
|
| 620 |
+
path, filename = None, ""
|
| 621 |
+
if pdf:
|
| 622 |
+
data = await pdf.read()
|
| 623 |
+
if len(data) > MAX_UPLOAD_MB * 1e6:
|
| 624 |
+
raise HTTPException(413, f"PDF larger than {MAX_UPLOAD_MB:.0f} MB")
|
| 625 |
+
if not data.startswith(b"%PDF"):
|
| 626 |
+
raise HTTPException(400, "that file is not a PDF")
|
| 627 |
+
filename = Path(pdf.filename or "paper.pdf").name
|
| 628 |
+
path = work / "upload.pdf"
|
| 629 |
+
path.write_bytes(data)
|
| 630 |
+
jid = uuid.uuid4().hex[:10]
|
| 631 |
+
JOBS[jid] = {"id": jid, "status": "queued", "log": [], "file": filename, "ident": ident,
|
| 632 |
+
"by": who, "ts": time.time()}
|
| 633 |
+
threading.Thread(target=_run_upload, daemon=True,
|
| 634 |
+
args=(jid, work, path, ident, title.strip(), venue.strip(), add_to_pool, who, filename)).start()
|
| 635 |
+
return JOBS[jid]
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
@app.get("/api/admin/jobs")
|
| 639 |
+
def admin_jobs(_a: str = Depends(require_admin)):
|
| 640 |
+
return {"jobs": sorted(JOBS.values(), key=lambda j: -j["ts"])[:30]}
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
class PoolOp(BaseModel):
|
| 644 |
+
paper: str
|
| 645 |
+
include: bool
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
@app.post("/api/admin/pool")
|
| 649 |
+
def admin_pool(op: PoolOp, who: str = Depends(require_admin)):
|
| 650 |
+
if op.paper not in PAPERS.manifest()["papers"]:
|
| 651 |
+
raise HTTPException(404, "unknown paper")
|
| 652 |
+
pool = list(PAPERS.pool() or [])
|
| 653 |
+
if op.include and op.paper not in pool:
|
| 654 |
+
pool.append(op.paper)
|
| 655 |
+
elif not op.include and op.paper in pool:
|
| 656 |
+
pool.remove(op.paper)
|
| 657 |
+
else:
|
| 658 |
+
return {"pool": pool}
|
| 659 |
+
PAPERS.set_pool(pool, message=f"papers: {'add' if op.include else 'remove'} {op.paper} "
|
| 660 |
+
f"{'to' if op.include else 'from'} the annotator pool ({who})")
|
| 661 |
+
return {"pool": pool}
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
if __name__ == "__main__":
|
| 665 |
+
import uvicorn
|
| 666 |
+
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8420")))
|
app/requirements-convert.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
docling>=2.50
|
| 2 |
+
pymupdf>=1.24
|
app/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn>=0.29
|
| 3 |
+
pydantic>=2
|
| 4 |
+
httpx>=0.27
|
| 5 |
+
python-multipart>=0.0.9
|
app/static/index.html
ADDED
|
@@ -0,0 +1,1215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>Reproducibility Annotator</title>
|
| 6 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
| 7 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 8 |
+
<style>
|
| 9 |
+
:root{
|
| 10 |
+
--bg:#f6f7f9; --panel:#ffffff; --ink:#1d2430; --muted:#6b7686; --line:#e3e7ee;
|
| 11 |
+
--accent:#d97706; --accent-soft:#fff3e0; --blue:#2563eb; --green:#15803d; --red:#b91c1c;
|
| 12 |
+
--na:#eef0f4;
|
| 13 |
+
}
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
html,body{height:100%;margin:0}
|
| 16 |
+
body{font:14px/1.55 -apple-system,"PingFang SC","Helvetica Neue",sans-serif;color:var(--ink);background:var(--bg)}
|
| 17 |
+
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:6px;padding:4px 10px}
|
| 18 |
+
button:hover{border-color:#b9c2d0}
|
| 19 |
+
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
| 20 |
+
select,input[type=text],textarea{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;background:#fff;color:var(--ink);width:100%}
|
| 21 |
+
textarea{resize:vertical;min-height:34px}
|
| 22 |
+
.muted{color:var(--muted)}
|
| 23 |
+
/* ---------- login ---------- */
|
| 24 |
+
#login{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:50}
|
| 25 |
+
#login .box{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:28px 32px;width:420px;box-shadow:0 8px 30px rgba(0,0,0,.06)}
|
| 26 |
+
#login h1{font-size:18px;margin:0 0 4px}
|
| 27 |
+
#login .sub{font-size:12px;color:var(--muted);margin-bottom:18px}
|
| 28 |
+
#login label{display:block;font-size:12px;color:var(--muted);margin:12px 0 4px}
|
| 29 |
+
#login input[type=password]{font:inherit;border:1px solid var(--line);border-radius:6px;padding:4px 8px;width:100%}
|
| 30 |
+
.login-err{color:var(--red);font-size:12px;min-height:16px;margin-top:8px}
|
| 31 |
+
/* ---------- home ---------- */
|
| 32 |
+
:root{--c-l1:#475569;--c-dataset:#2563eb;--c-split:#0891b2;--c-asset:#0d9488;--c-model:#7c3aed;--c-result:#d97706;--c-claim:#e11d48}
|
| 33 |
+
#home{display:none;min-height:100vh}
|
| 34 |
+
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;padding:10px 24px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
|
| 35 |
+
.brand{font-weight:700;font-size:15px;letter-spacing:-.01em}
|
| 36 |
+
.brand small{font-weight:400;color:var(--muted);margin-left:6px}
|
| 37 |
+
.topbar .spacer{flex:1}
|
| 38 |
+
.topbar .chip{font-size:12px;background:var(--na);border-radius:99px;padding:3px 10px}
|
| 39 |
+
.topbar select{width:auto;font-size:12px;padding:3px 8px}
|
| 40 |
+
.home-wrap{max-width:1320px;margin:0 auto;padding:24px}
|
| 41 |
+
.home-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:6px}
|
| 42 |
+
.home-head h2{margin:0;font-size:22px;letter-spacing:-.02em}
|
| 43 |
+
.home-stats{color:var(--muted);font-size:13px}
|
| 44 |
+
.blind-note{font-size:12px;color:var(--muted);margin:4px 0 18px}
|
| 45 |
+
.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px}
|
| 46 |
+
.filters input{max-width:320px}
|
| 47 |
+
.pill{font-size:12px;border:1px solid var(--line);background:#fff;border-radius:99px;padding:4px 12px;cursor:pointer;color:var(--muted);white-space:nowrap}
|
| 48 |
+
.pill.on{background:var(--ink);border-color:var(--ink);color:#fff}
|
| 49 |
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
|
| 50 |
+
.pcard{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 16px 14px;cursor:pointer;display:flex;flex-direction:column;gap:8px;transition:box-shadow .15s,transform .15s,border-color .15s;min-height:230px}
|
| 51 |
+
.pcard:hover{box-shadow:0 10px 28px rgba(15,23,42,.08);transform:translateY(-2px);border-color:#cfd6e2}
|
| 52 |
+
.pcard .meta{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted)}
|
| 53 |
+
.venue{font-size:11px;font-weight:600;border-radius:6px;padding:2px 8px;background:var(--na);color:var(--ink)}
|
| 54 |
+
.pcard h3{margin:0;font-size:14.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
| 55 |
+
.pcard .abs{font-size:12.5px;color:var(--muted);line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;flex:1}
|
| 56 |
+
.pcard .foot{display:flex;align-items:center;gap:10px;border-top:1px dashed var(--line);padding-top:10px}
|
| 57 |
+
.blocks{display:flex;gap:3px;flex:1}
|
| 58 |
+
.blk{flex:1;height:22px;border-radius:5px;background:var(--na);font-size:10px;font-weight:600;color:#9aa3b2;display:flex;align-items:center;justify-content:center}
|
| 59 |
+
.blk.on{color:#fff}
|
| 60 |
+
.status{font-size:11px;border-radius:99px;padding:2px 9px;white-space:nowrap}
|
| 61 |
+
.status.todo{background:var(--na);color:var(--muted)}
|
| 62 |
+
.status.doing{background:var(--accent-soft);color:var(--accent)}
|
| 63 |
+
.empty{color:var(--muted);text-align:center;padding:60px 0}
|
| 64 |
+
/* ---------- 1003: admin upload + pool ---------- */
|
| 65 |
+
.pool-pill{font-size:11px;border-radius:99px;padding:2px 9px;border:1px solid var(--line);background:#fff;color:var(--muted);cursor:pointer;white-space:nowrap}
|
| 66 |
+
.pool-pill.on{background:#e7f6ec;border-color:#bfe3cb;color:var(--green)}
|
| 67 |
+
.src-tag{font-size:10.5px;border-radius:4px;padding:1px 6px;background:var(--na);color:var(--muted)}
|
| 68 |
+
#upload{display:none;position:fixed;inset:0;z-index:70;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px}
|
| 69 |
+
#upload .box{background:var(--panel);border-radius:14px;width:min(640px,100%);max-height:100%;overflow:auto;padding:22px 24px;box-shadow:0 30px 80px rgba(0,0,0,.25)}
|
| 70 |
+
#upload h3{margin:0 0 4px;font-size:17px}
|
| 71 |
+
#upload .sub{font-size:12.5px;color:var(--muted);margin-bottom:14px;line-height:1.5}
|
| 72 |
+
#upload label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
|
| 73 |
+
#upload .row{display:flex;gap:10px}
|
| 74 |
+
#upload .row>div{flex:1}
|
| 75 |
+
#upload .acts{display:flex;align-items:center;gap:10px;margin-top:16px}
|
| 76 |
+
#up-err{color:var(--red);font-size:12px}
|
| 77 |
+
.job{border:1px solid var(--line);border-radius:10px;padding:8px 10px;margin-top:10px;font-size:12.5px}
|
| 78 |
+
.job .st{font-weight:600}
|
| 79 |
+
.job .st.done{color:var(--green)} .job .st.error{color:var(--red)} .job .st.running,.job .st.queued{color:var(--accent)}
|
| 80 |
+
.job pre{margin:6px 0 0;font-size:11.5px;white-space:pre-wrap;color:var(--muted);font-family:ui-monospace,monospace;max-height:140px;overflow:auto}
|
| 81 |
+
/* ---------- detail modal ---------- */
|
| 82 |
+
#detail{display:none;position:fixed;inset:0;z-index:60;background:rgba(15,23,42,.45);align-items:center;justify-content:center;padding:24px}
|
| 83 |
+
#detail .modal{background:var(--bg);border-radius:16px;width:min(1400px,100%);height:min(920px,100%);display:flex;flex-direction:column;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.25)}
|
| 84 |
+
.m-head{display:flex;align-items:flex-start;gap:14px;padding:16px 20px;background:var(--panel);border-bottom:1px solid var(--line)}
|
| 85 |
+
.m-head h2{margin:4px 0 0;font-size:17px;line-height:1.35}
|
| 86 |
+
.m-head .x{margin-left:auto;font-size:18px;line-height:1;padding:4px 10px}
|
| 87 |
+
.m-body{flex:1;display:flex;min-height:0}
|
| 88 |
+
.m-pdf{flex:1.1;min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:#525659}
|
| 89 |
+
.m-pdf iframe{flex:1;border:0;width:100%}
|
| 90 |
+
.m-pdf .nopdf{color:#fff;margin:auto;opacity:.8}
|
| 91 |
+
.m-side{flex:1;min-width:0;display:flex;flex-direction:column}
|
| 92 |
+
.m-cards{flex:1;overflow:auto;padding:16px 18px;display:grid;grid-template-columns:1fr 1fr;gap:12px;align-content:start}
|
| 93 |
+
.bcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 14px;border-top:3px solid var(--bc,var(--line))}
|
| 94 |
+
.bcard.wide{grid-column:1/-1}
|
| 95 |
+
.bcard h4{margin:0 0 2px;font-size:13px;display:flex;align-items:center;gap:6px}
|
| 96 |
+
.bcard .sub{font-size:11.5px;color:var(--muted);margin-bottom:8px}
|
| 97 |
+
.bcard ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
|
| 98 |
+
.bcard li{font-size:12.5px;line-height:1.45;background:#fafbfd;border:1px solid #eef1f6;border-radius:8px;padding:6px 8px}
|
| 99 |
+
.bcard li .k{color:var(--muted);font-size:11px;margin-right:4px}
|
| 100 |
+
.bcard li .tag{font-size:10.5px;border-radius:4px;padding:0 5px;background:var(--na);color:var(--muted);margin-left:4px;white-space:nowrap;display:inline-block}
|
| 101 |
+
.bcard .none{font-size:12px;color:#a0a8b6;font-style:italic}
|
| 102 |
+
.bar{height:4px;background:var(--na);border-radius:99px;overflow:hidden;margin:6px 0 8px}
|
| 103 |
+
.bar i{display:block;height:100%;background:var(--bc)}
|
| 104 |
+
.m-foot{display:flex;align-items:center;gap:10px;padding:12px 18px;background:var(--panel);border-top:1px solid var(--line)}
|
| 105 |
+
.m-foot .spacer{flex:1}
|
| 106 |
+
.m-foot button.primary{padding:7px 18px;font-weight:600;white-space:nowrap}
|
| 107 |
+
.m-foot a{white-space:nowrap}
|
| 108 |
+
@media (max-width:900px){.m-body{flex-direction:column}.m-pdf{flex:none;height:45%}.m-cards{grid-template-columns:1fr}}
|
| 109 |
+
header .chip.sync{cursor:pointer}
|
| 110 |
+
header .chip.sync.ok{background:#e7f6ec;color:var(--green)}
|
| 111 |
+
header .chip.sync.pending{background:var(--accent-soft);color:var(--accent)}
|
| 112 |
+
header .chip.sync.err{background:#fde8e8;color:var(--red)}
|
| 113 |
+
/* ---------- layout ---------- */
|
| 114 |
+
#app{display:none;height:100vh;flex-direction:column}
|
| 115 |
+
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
|
| 116 |
+
header .title{font-weight:600}
|
| 117 |
+
header .chip{font-size:12px;background:var(--na);border-radius:99px;padding:2px 10px}
|
| 118 |
+
header .spacer{flex:1}
|
| 119 |
+
#main{flex:1;display:flex;min-height:0}
|
| 120 |
+
#left,#right{min-width:0;display:flex;flex-direction:column}
|
| 121 |
+
#left{width:46%;border-right:1px solid var(--line)}
|
| 122 |
+
#right{flex:1}
|
| 123 |
+
.tabs{display:flex;gap:2px;padding:6px 10px 0;background:var(--panel);border-bottom:1px solid var(--line);flex-wrap:wrap}
|
| 124 |
+
.tab{padding:5px 12px;border:1px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap}
|
| 125 |
+
.tab.on{background:var(--bg);border-color:var(--line);color:var(--ink);font-weight:600}
|
| 126 |
+
.tab .cnt{font-size:11px;color:var(--muted)}
|
| 127 |
+
.pane{flex:1;overflow:auto;min-height:0}
|
| 128 |
+
/* ---------- left ---------- */
|
| 129 |
+
#textpane{padding:16px 20px;white-space:pre-wrap;font-size:13.5px;line-height:1.75;user-select:text}
|
| 130 |
+
#textpane mark{background:#ffe9a8;border-radius:3px}
|
| 131 |
+
#textpane table{border-collapse:collapse;margin:10px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
|
| 132 |
+
#textpane th,#textpane td{border:1px solid var(--line);padding:3px 8px;text-align:left;white-space:nowrap}
|
| 133 |
+
#textpane th{background:#fbfcfe;font-weight:600}
|
| 134 |
+
#textpane h2,#textpane h3,#textpane h4,#textpane h5,#textpane h6{margin:14px 0 6px;line-height:1.3}
|
| 135 |
+
#textpane h2{font-size:16px} #textpane h3{font-size:14.5px} #textpane h4,#textpane h5,#textpane h6{font-size:13.5px}
|
| 136 |
+
#textpane p{margin:6px 0}
|
| 137 |
+
#textpane .flash{background:#ffe9a8;transition:background .3s}
|
| 138 |
+
/* 1003: PDF ↔ text side by side */
|
| 139 |
+
#splitter{flex:none;width:6px;cursor:col-resize;background:var(--line)}
|
| 140 |
+
#splitter:hover,#splitter.drag{background:var(--accent)}
|
| 141 |
+
.lpane{flex:1;display:flex;min-height:0}
|
| 142 |
+
.lpane>div{flex:1;min-width:0;overflow:auto}
|
| 143 |
+
#pdfview{background:#525659;padding:12px;position:relative;border-right:1px solid var(--line)}
|
| 144 |
+
#pdfview .nopdf{color:#fff;opacity:.8;text-align:center;margin-top:40px}
|
| 145 |
+
#pdfview .pg{position:relative;margin:0 auto 12px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
|
| 146 |
+
#pdfview .pg canvas{position:absolute;left:0;top:0}
|
| 147 |
+
.textLayer{position:absolute;inset:0;overflow:hidden;line-height:1;text-size-adjust:none;forced-color-adjust:none;transform-origin:0 0;z-index:2}
|
| 148 |
+
.textLayer span,.textLayer br{color:transparent;position:absolute;white-space:pre;cursor:text;transform-origin:0% 0%}
|
| 149 |
+
.textLayer ::selection{background:rgba(37,99,235,.3)}
|
| 150 |
+
.textLayer .endOfContent{display:block;position:absolute;inset:100% 0 0;z-index:-1;cursor:default;user-select:none}
|
| 151 |
+
.hl-layer{position:absolute;inset:0;pointer-events:none;z-index:3}
|
| 152 |
+
.hl-layer i{position:absolute;border-radius:3px}
|
| 153 |
+
.hl-layer i.blk{background:rgba(217,119,6,.12);outline:2px solid rgba(217,119,6,.75)}
|
| 154 |
+
.hl-layer i.ev{background:rgba(250,204,21,.35);outline:2px solid #eab308}
|
| 155 |
+
#textpane .tb{border-left:3px solid transparent;padding-left:8px;margin-left:-11px;border-radius:2px}
|
| 156 |
+
#textpane.linked .tb:hover{border-left-color:#d6dbe4}
|
| 157 |
+
#textpane .tb.cur{border-left-color:var(--accent);background:#fffaf0}
|
| 158 |
+
#textpane .tb.k-figure,#textpane .tb.k-formula{color:var(--muted);font-style:italic}
|
| 159 |
+
#textpane .tb.k-caption{font-size:12.5px;font-weight:600;color:#374151}
|
| 160 |
+
#textpane .tb.k-footnote,#textpane .tb.k-reference{font-size:12px;color:var(--muted)}
|
| 161 |
+
.src-chip{margin-left:auto;align-self:center;font-size:11px;border-radius:99px;padding:2px 9px;margin-bottom:4px;white-space:nowrap}
|
| 162 |
+
.src-chip.good{background:#e7f6ec;color:var(--green)}
|
| 163 |
+
.src-chip.ok{background:#eff6ff;color:#1d4ed8}
|
| 164 |
+
.src-chip.weak{background:#fde8e8;color:var(--red)}
|
| 165 |
+
.ev .pdf-tag{font-size:10px;font-weight:600;border-radius:4px;padding:0 4px;background:#eff6ff;color:#1d4ed8;cursor:pointer}
|
| 166 |
+
.ev .g-pdf{color:var(--accent);font-weight:600}
|
| 167 |
+
#seltool{position:fixed;display:none;z-index:40;background:var(--ink);color:#fff;border-radius:8px;padding:6px 10px;font-size:12px;box-shadow:0 4px 14px rgba(0,0,0,.25);cursor:pointer}
|
| 168 |
+
/* ---------- right ---------- */
|
| 169 |
+
.rt-help{font-size:12px;color:var(--muted);padding:8px 14px 0}
|
| 170 |
+
.recwrap{padding:10px 14px 80px}
|
| 171 |
+
.record{background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-bottom:14px}
|
| 172 |
+
.rec-head{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line);background:#fbfcfe;border-radius:10px 10px 0 0;flex-wrap:wrap}
|
| 173 |
+
.rec-head .rlabel{font-weight:600;font-size:13px}
|
| 174 |
+
.rec-head .uid{font-size:11px;color:var(--muted)}
|
| 175 |
+
.extras{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px}
|
| 176 |
+
.extras select{width:auto}
|
| 177 |
+
.group{border-top:1px solid var(--line)}
|
| 178 |
+
.group:first-of-type{border-top:none}
|
| 179 |
+
.group-title{font-size:12px;font-weight:600;color:var(--muted);padding:8px 12px 0;cursor:pointer}
|
| 180 |
+
.group-title .tri{display:inline-block;transition:.15s}
|
| 181 |
+
.group.closed .tri{transform:rotate(-90deg)}
|
| 182 |
+
.group.closed .field{display:none}
|
| 183 |
+
/* ---------- field card ---------- */
|
| 184 |
+
.field{padding:8px 12px;border-top:1px dashed #eef1f6}
|
| 185 |
+
.field.active{background:var(--accent-soft);outline:2px solid var(--accent);outline-offset:-2px;border-radius:6px}
|
| 186 |
+
.field.na{background:var(--na);opacity:.65}
|
| 187 |
+
.f-head{display:flex;align-items:baseline;gap:8px;cursor:pointer}
|
| 188 |
+
.f-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--blue);font-weight:600}
|
| 189 |
+
.f-label{font-size:13px}
|
| 190 |
+
.badge{font-size:10px;border-radius:4px;padding:1px 5px;font-weight:600}
|
| 191 |
+
.b-novel{background:#fdf2f8;color:#be185d}
|
| 192 |
+
.b-op{background:#eff6ff;color:#1d4ed8}
|
| 193 |
+
.b-gold{background:#fef9c3;color:#a16207}
|
| 194 |
+
.b-verb{background:#f0fdf4;color:#15803d}
|
| 195 |
+
.b-na{background:#e2e8f0;color:#475569}
|
| 196 |
+
.f-done{margin-left:auto;font-size:11px;color:var(--green)}
|
| 197 |
+
.f-body{margin-top:6px;display:grid;grid-template-columns:150px 170px 1fr;gap:6px;align-items:start}
|
| 198 |
+
.f-body .full{grid-column:1/-1}
|
| 199 |
+
.f-hint{grid-column:1/-1;font-size:11px;color:var(--muted)}
|
| 200 |
+
.ev-list{grid-column:1/-1;display:flex;flex-direction:column;gap:4px}
|
| 201 |
+
.ev{display:flex;gap:6px;align-items:center;background:#fbfcfe;border:1px solid var(--line);border-radius:6px;padding:4px 6px;font-size:12px}
|
| 202 |
+
.ev .q{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
|
| 203 |
+
.ev select{width:auto;font-size:11px;padding:2px 4px}
|
| 204 |
+
.ev .g-ok{color:var(--green)} .ev .g-bad{color:var(--red);font-weight:600}
|
| 205 |
+
.ev label{display:flex;gap:2px;align-items:center;font-size:11px;color:var(--muted);white-space:nowrap}
|
| 206 |
+
.axes-row{display:flex;gap:6px;margin-bottom:4px}
|
| 207 |
+
.axes-row input{width:140px}
|
| 208 |
+
.fk-box{border:1px solid var(--line);border-radius:6px;background:#fff;padding:4px 8px;max-height:110px;overflow:auto}
|
| 209 |
+
.fk-box label{display:flex;gap:6px;font-size:12px;align-items:center}
|
| 210 |
+
.addbar{padding:0 14px 20px}
|
| 211 |
+
.footer-tip{flex:none;background:var(--panel);border-top:1px solid var(--line);padding:5px 14px;font-size:11px;color:var(--muted)}
|
| 212 |
+
</style>
|
| 213 |
+
</head>
|
| 214 |
+
<body>
|
| 215 |
+
|
| 216 |
+
<div id="login">
|
| 217 |
+
<div class="box">
|
| 218 |
+
<h1>Reproducibility Annotator</h1>
|
| 219 |
+
<div class="sub">Blind mode · schema-driven · full audit trail. Do not consult any model output.</div>
|
| 220 |
+
<div id="step-login">
|
| 221 |
+
<div id="quick-ids" style="display:none">
|
| 222 |
+
<label>Who are you?</label>
|
| 223 |
+
<div id="quick-list" style="display:flex;gap:8px;flex-wrap:wrap"></div>
|
| 224 |
+
<label style="margin-top:16px">or type an ID</label>
|
| 225 |
+
</div>
|
| 226 |
+
<label id="id-label">Annotator ID (e.g. A / B, keep it consistent)</label>
|
| 227 |
+
<input type="text" id="in-annotator" placeholder="A" autocomplete="username">
|
| 228 |
+
<div id="pw-wrap" style="display:none">
|
| 229 |
+
<label>Password</label>
|
| 230 |
+
<input type="password" id="in-password" autocomplete="current-password">
|
| 231 |
+
</div>
|
| 232 |
+
<div class="login-err" id="login-err"></div>
|
| 233 |
+
<div style="margin-top:18px;text-align:right"><button class="primary" id="btn-login">Sign in</button></div>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div id="home">
|
| 239 |
+
<div class="topbar">
|
| 240 |
+
<span class="brand">Reproducibility Annotator<small>independent double annotation</small></span>
|
| 241 |
+
<span class="chip" id="hm-schema"></span>
|
| 242 |
+
<span class="spacer"></span>
|
| 243 |
+
<button id="hm-add" class="primary" style="display:none">+ Add paper</button>
|
| 244 |
+
<span id="hm-view" style="display:none;font-size:12px;color:var(--muted)">Viewing annotator <select id="hm-annotator"></select></span>
|
| 245 |
+
<span class="chip" id="hm-who"></span>
|
| 246 |
+
<button id="hm-logout">Sign out</button>
|
| 247 |
+
</div>
|
| 248 |
+
<div class="home-wrap">
|
| 249 |
+
<div class="home-head">
|
| 250 |
+
<h2>Papers</h2>
|
| 251 |
+
<span class="home-stats" id="hm-stats"></span>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="blind-note" id="hm-note">Blind mode: you only see your own annotations. Open a card to preview the PDF and your annotation cards.</div>
|
| 254 |
+
<div class="filters">
|
| 255 |
+
<input type="text" id="hm-q" placeholder="Search title or ID">
|
| 256 |
+
<span id="hm-venues"></span>
|
| 257 |
+
<span style="width:8px"></span>
|
| 258 |
+
<span id="hm-status"></span>
|
| 259 |
+
</div>
|
| 260 |
+
<div class="grid" id="hm-grid"></div>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
|
| 264 |
+
<div id="upload">
|
| 265 |
+
<div class="box">
|
| 266 |
+
<h3>Add a paper</h3>
|
| 267 |
+
<div class="sub">Upload a PDF, or just give a PMCID / DOI / PMID. If the paper is in the PMC open-access subset the publisher's XML is fetched automatically (best text); otherwise the PDF is converted with Docling. Conversion takes about 10 s – 2 min. Papers are only ever added, never overwritten.</div>
|
| 268 |
+
<label>PDF</label>
|
| 269 |
+
<input type="file" id="up-pdf" accept="application/pdf,.pdf">
|
| 270 |
+
<label>PMCID / DOI / PMID <span class="muted">(optional; detected from the PDF if left empty)</span></label>
|
| 271 |
+
<input type="text" id="up-ident" placeholder="e.g. PMC4502302 or 10.1155/2015/605940">
|
| 272 |
+
<div class="row">
|
| 273 |
+
<div><label>Title <span class="muted">(optional)</span></label><input type="text" id="up-title"></div>
|
| 274 |
+
<div style="max-width:160px"><label>Venue <span class="muted">(optional)</span></label><input type="text" id="up-venue" placeholder="e.g. MICCAI"></div>
|
| 275 |
+
</div>
|
| 276 |
+
<label style="display:flex;gap:6px;align-items:center;color:var(--ink);font-size:13px;margin-top:14px"><input type="checkbox" id="up-pool" checked> Add to the annotators' pool (everyone except admin sees only pool papers)</label>
|
| 277 |
+
<div class="acts"><button class="primary" id="up-go">Upload & convert</button><button id="up-close">Close</button><span id="up-err"></span></div>
|
| 278 |
+
<div id="up-jobs"></div>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
|
| 282 |
+
<div id="detail">
|
| 283 |
+
<div class="modal">
|
| 284 |
+
<div class="m-head">
|
| 285 |
+
<div style="min-width:0">
|
| 286 |
+
<div class="meta" id="dt-meta" style="display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted)"></div>
|
| 287 |
+
<h2 id="dt-title"></h2>
|
| 288 |
+
</div>
|
| 289 |
+
<button class="x" id="dt-close" title="Close (Esc)">×</button>
|
| 290 |
+
</div>
|
| 291 |
+
<div class="m-body">
|
| 292 |
+
<div class="m-pdf" id="dt-pdf"></div>
|
| 293 |
+
<div class="m-side">
|
| 294 |
+
<div class="m-cards" id="dt-cards"></div>
|
| 295 |
+
<div class="m-foot">
|
| 296 |
+
<span class="muted" id="dt-sum" style="font-size:12px"></span>
|
| 297 |
+
<span class="spacer"></span>
|
| 298 |
+
<a id="dt-pdflink" target="_blank" style="font-size:12px">Open PDF in new tab ↗</a>
|
| 299 |
+
<button class="primary" id="dt-start">Start annotating</button>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
<div id="app">
|
| 307 |
+
<header>
|
| 308 |
+
<span class="title" id="h-paper"></span>
|
| 309 |
+
<span class="chip" id="h-annotator"></span>
|
| 310 |
+
<span class="chip" id="h-schema"></span>
|
| 311 |
+
<span class="chip" id="h-progress">0 fields annotated</span>
|
| 312 |
+
<span class="spacer"></span>
|
| 313 |
+
<span class="chip sync" id="h-sync" title="Click to sync now">…</span>
|
| 314 |
+
<button id="btn-switch">← Papers</button>
|
| 315 |
+
<button id="btn-logout">Sign out</button>
|
| 316 |
+
</header>
|
| 317 |
+
<div id="main">
|
| 318 |
+
<div id="left">
|
| 319 |
+
<div class="tabs" id="lt-tabs"></div>
|
| 320 |
+
<div class="lpane"><div id="pdfview"></div><div id="textpane"></div></div>
|
| 321 |
+
</div>
|
| 322 |
+
<div id="splitter" title="Drag to resize"></div>
|
| 323 |
+
<div id="right">
|
| 324 |
+
<div class="tabs" id="rt-tabs"></div>
|
| 325 |
+
<div class="rt-help" id="rt-help"></div>
|
| 326 |
+
<div class="pane"><div class="recwrap" id="recwrap"></div></div>
|
| 327 |
+
<div class="footer-tip">Select text in the text pane <b>or on the PDF</b> → "Add as evidence" or ⌘/Ctrl+E attaches it to the active field · click a paragraph to see it on the PDF (and vice versa) · if text and PDF disagree, trust the PDF and tick dmg · ✓ grounded / ✗ mismatch · autosave</div>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<div id="seltool">Add as evidence ⌘E</div>
|
| 333 |
+
|
| 334 |
+
<script>
|
| 335 |
+
"use strict";
|
| 336 |
+
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
| 337 |
+
const api=(p,opt)=>fetch(p,opt).then(r=>{
|
| 338 |
+
if(r.status===401&&S.annotator){alert("Session expired, please sign in again");location.reload();}
|
| 339 |
+
if(!r.ok)throw new Error(p+" "+r.status);return r.json()});
|
| 340 |
+
const lsGet=()=>{try{return JSON.parse(localStorage.getItem("repl_ann")||"{}")}catch(e){return {}}};
|
| 341 |
+
const lsSet=v=>{try{localStorage.setItem("repl_ann",JSON.stringify(v))}catch(e){}};
|
| 342 |
+
|
| 343 |
+
const S={annotator:null,paper:null,schema:null,manifest:null,records:[],fields:{},rtype:null,active:null,rawText:"",saveTimers:{}};
|
| 344 |
+
|
| 345 |
+
/* ---------------- boot / login ---------------- */
|
| 346 |
+
(async function(){
|
| 347 |
+
const cfg=await fetch("/api/config").then(r=>r.json());
|
| 348 |
+
S.authMode=cfg.auth;
|
| 349 |
+
$("#pw-wrap").style.display=cfg.auth?"block":"none";
|
| 350 |
+
if(!cfg.auth&&cfg.annotators&&cfg.annotators.length){
|
| 351 |
+
$("#quick-ids").style.display="block";$("#id-label").style.display="none";
|
| 352 |
+
$("#quick-list").innerHTML=cfg.annotators.map(a=>`<button class="qid" data-id="${esc(a)}" style="padding:6px 16px;font-weight:600">${esc(a)}</button>`).join("");
|
| 353 |
+
$("#quick-list").onclick=e=>{const b=e.target.closest(".qid");if(b){$("#in-annotator").value=b.dataset.id;doLogin()}};
|
| 354 |
+
}
|
| 355 |
+
const saved=lsGet();
|
| 356 |
+
if(saved.annotator)$("#in-annotator").value=saved.annotator;
|
| 357 |
+
const me=await fetch("/api/me");
|
| 358 |
+
if(me.ok)await enterHome(await me.json());
|
| 359 |
+
})();
|
| 360 |
+
|
| 361 |
+
async function doLogin(){
|
| 362 |
+
const a=$("#in-annotator").value.trim(), pw=$("#in-password").value;
|
| 363 |
+
if(!a){$("#login-err").textContent="Enter an annotator ID";return}
|
| 364 |
+
const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({annotator:a,password:pw})});
|
| 365 |
+
if(!r.ok){$("#login-err").textContent=r.status===401?(S.authMode?"Wrong ID or password":"Unknown annotator ID"):r.status===400?"ID: letters, digits, _ or - only":r.status===403?"This ID cannot sign in":"Login failed ("+r.status+")";return}
|
| 366 |
+
$("#login-err").textContent="";
|
| 367 |
+
await enterHome(await api("/api/me"));
|
| 368 |
+
}
|
| 369 |
+
$("#btn-login").onclick=doLogin;
|
| 370 |
+
$("#in-password").addEventListener("keydown",e=>{if(e.key==="Enter")doLogin()});
|
| 371 |
+
$("#in-annotator").addEventListener("keydown",e=>{if(e.key==="Enter"&&!S.authMode)doLogin()});
|
| 372 |
+
|
| 373 |
+
/* ---------------- home: paper cards ---------------- */
|
| 374 |
+
const enc=encodeURIComponent;
|
| 375 |
+
const BLOCKS=[["l1","L1"],["dataset","D"],["split","S"],["asset","A"],["model","M"],["result","R"],["claim","C"]];
|
| 376 |
+
/* All UI text is English (0925). Schema labels carry Chinese in parentheses; stripped for display (schema file untouched). */
|
| 377 |
+
const EN_RT={l1:"L1 characterization",dataset:"Datasets",split:"Splits",asset:"Artifacts (code / weights / environment)",model:"Models",result:"Results",claim:"Claims"};
|
| 378 |
+
const EN_STATUS={stated:"stated",inferred:"inferred",not_reported:"not reported",absent_deferred:"deferred elsewhere",uncertain:"uncertain"};
|
| 379 |
+
const prettyEnum=t=>String(t).replace(/^C\d_/,"").replace(/_/g," ");
|
| 380 |
+
const stripZh=t=>typeof t==="string"?t.replace(/\s*[((](?=[^()()]*[\u4e00-\u9fff])[^()()]*[))]/g,"").trim():t;
|
| 381 |
+
function enSchema(x){if(Array.isArray(x))return x.map(enSchema);if(x&&typeof x==="object"){const o={};for(const [k,v] of Object.entries(x))o[k]=(k==="label"||k==="title"||k==="help"||k==="hint")?stripZh(v):enSchema(v);return o}return x}
|
| 382 |
+
const H={view:null,overview:{},q:"",venue:"all",status:"all",pid:null};
|
| 383 |
+
const rtDef=id=>S.schema.record_types.find(r=>r.id===id);
|
| 384 |
+
const fieldDefs=rt=>rt.fields||(rt.groups||[]).flatMap(g=>g.fields||[]);
|
| 385 |
+
const isFilled=p=>!!(p&&(p.status||p.value||(p.evidence&&p.evidence.length)||(p.fk&&p.fk.length)||(p.axes&&p.axes.length)||p.no_anchor));
|
| 386 |
+
function valOf(p,max=90){
|
| 387 |
+
if(!p)return "";
|
| 388 |
+
let v=p.value;
|
| 389 |
+
if(v&&typeof v==="object")v=Array.isArray(v)?v.join(", "):Object.values(v).filter(Boolean).join(" · ");
|
| 390 |
+
v=(v||"").toString().trim();
|
| 391 |
+
if(!v&&p.status)v="("+(EN_STATUS[p.status]||p.status)+")";
|
| 392 |
+
return v.length>max?v.slice(0,max)+"…":v;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
async function enterHome(me){
|
| 396 |
+
S.me=me;
|
| 397 |
+
if(!S.schema)S.schema=enSchema(await api("/api/schema"));
|
| 398 |
+
if(!S.manifest)S.manifest=await api("/api/papers");
|
| 399 |
+
H.view=H.view||me.annotator;
|
| 400 |
+
$("#hm-schema").textContent="schema "+S.schema.schema_version;
|
| 401 |
+
$("#hm-who").textContent="Signed in: "+me.annotator+(me.admin?" (admin)":"");
|
| 402 |
+
if(me.admin){
|
| 403 |
+
$("#hm-view").style.display="inline";$("#hm-add").style.display="inline-block";
|
| 404 |
+
$("#hm-annotator").innerHTML=me.annotators.map(a=>`<option value="${esc(a)}" ${a===H.view?"selected":""}>${esc(a)}</option>`).join("");
|
| 405 |
+
$("#hm-note").textContent="Admin view: switch annotator at the top right to read anyone's cards (read-only). Annotators only ever see their own.";
|
| 406 |
+
}
|
| 407 |
+
const venues=[...new Set(Object.values(S.manifest.papers).map(e=>e.venue).filter(Boolean))].sort();
|
| 408 |
+
$("#hm-venues").innerHTML=["all",...venues].map(v=>`<span class="pill ${v===H.venue?"on":""}" data-venue="${esc(v)}">${v==="all"?"All venues":esc(v)}</span>`).join(" ");
|
| 409 |
+
$("#hm-status").innerHTML=[["all","Any status"],["todo","Not started"],["doing","In progress"]].map(([k,l])=>`<span class="pill ${k===H.status?"on":""}" data-status="${k}">${l}</span>`).join(" ");
|
| 410 |
+
$("#login").style.display="none";$("#app").style.display="none";$("#home").style.display="block";
|
| 411 |
+
await loadOverview();
|
| 412 |
+
}
|
| 413 |
+
async function loadOverview(){
|
| 414 |
+
$("#hm-grid").innerHTML=`<div class="empty">Loading…</div>`;
|
| 415 |
+
H.overview=(await api(`/api/overview?annotator=${enc(H.view)}`)).papers;
|
| 416 |
+
renderGrid();
|
| 417 |
+
}
|
| 418 |
+
const statusOf=pid=>(H.overview[pid]&&H.overview[pid].filled>0)?"doing":"todo";
|
| 419 |
+
function renderGrid(){
|
| 420 |
+
const all=Object.entries(S.manifest.papers);
|
| 421 |
+
const doing=all.filter(([p])=>statusOf(p)==="doing").length;
|
| 422 |
+
$("#hm-stats").textContent=`${all.length} papers · ${doing} in progress · ${all.length-doing} not started`+(H.view!==S.me.annotator?` · viewing ${H.view}`:"");
|
| 423 |
+
const q=H.q.toLowerCase();
|
| 424 |
+
const rows=all.filter(([pid,e])=>(H.venue==="all"||e.venue===H.venue)&&(H.status==="all"||statusOf(pid)===H.status)
|
| 425 |
+
&&(!q||pid.toLowerCase().includes(q)||(e.title||"").toLowerCase().includes(q)));
|
| 426 |
+
if(!rows.length){$("#hm-grid").innerHTML=`<div class="empty">No papers match</div>`;return}
|
| 427 |
+
$("#hm-grid").innerHTML=rows.map(([pid,e])=>{
|
| 428 |
+
const o=H.overview[pid]||{blocks:{},filled:0};
|
| 429 |
+
const blocks=BLOCKS.map(([id,sh])=>{
|
| 430 |
+
const b=o.blocks[id]||{records:0,filled:0},rt=rtDef(id);
|
| 431 |
+
const on=b.filled>0;
|
| 432 |
+
return `<span class="blk ${on?"on":""}" style="${on?`background:var(--c-${id})`:""}" title="${esc(EN_RT[id]||id)}: ${b.records} records · ${b.filled} fields filled">${sh}</span>`;
|
| 433 |
+
}).join("");
|
| 434 |
+
const st=statusOf(pid);
|
| 435 |
+
return `<div class="pcard" data-pid="${esc(pid)}">
|
| 436 |
+
<div class="meta"><span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>${e.text_source==="jats"?'<span class="src-tag">PMC XML</span>':""}${S.me.admin&&S.manifest.pool?`<span style="flex:1"></span><span class="pool-pill ${S.manifest.pool.includes(pid)?"on":""}" data-pool="${esc(pid)}" title="Whether annotators (A/B/C…) see this paper">${S.manifest.pool.includes(pid)?"✓ in pool":"not in pool"}</span>`:""}</div>
|
| 437 |
+
<h3>${esc(e.title||pid)}</h3>
|
| 438 |
+
<div class="abs">${esc(e.abstract||"")}</div>
|
| 439 |
+
<div class="foot"><div class="blocks">${blocks}</div>
|
| 440 |
+
<span class="status ${st}">${st==="doing"?o.filled+" fields":"Not started"}</span></div>
|
| 441 |
+
</div>`;
|
| 442 |
+
}).join("");
|
| 443 |
+
}
|
| 444 |
+
$("#hm-grid").onclick=async e=>{
|
| 445 |
+
const pp=e.target.closest(".pool-pill");
|
| 446 |
+
if(pp){
|
| 447 |
+
e.stopPropagation();const pid=pp.dataset.pool,inc=!S.manifest.pool.includes(pid);
|
| 448 |
+
pp.textContent="…";
|
| 449 |
+
try{const r=await api("/api/admin/pool",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({paper:pid,include:inc})});S.manifest.pool=r.pool}
|
| 450 |
+
catch(err){alert("Could not update the pool: "+err.message)}
|
| 451 |
+
renderGrid();return;
|
| 452 |
+
}
|
| 453 |
+
const c=e.target.closest(".pcard");if(c)openDetail(c.dataset.pid)};
|
| 454 |
+
|
| 455 |
+
/* ---------------- admin: add paper (1003) ---------------- */
|
| 456 |
+
const UP={timer:null};
|
| 457 |
+
$("#hm-add").onclick=()=>{$("#upload").style.display="flex";$("#up-err").textContent="";pollJobs()};
|
| 458 |
+
$("#up-close").onclick=()=>{$("#upload").style.display="none"};
|
| 459 |
+
$("#upload").onclick=e=>{if(e.target.id==="upload")$("#upload").style.display="none"};
|
| 460 |
+
$("#up-go").onclick=async()=>{
|
| 461 |
+
const f=$("#up-pdf").files[0],ident=$("#up-ident").value.trim();
|
| 462 |
+
if(!f&&!ident){$("#up-err").textContent="Choose a PDF or enter an ID";return}
|
| 463 |
+
const fd=new FormData();
|
| 464 |
+
if(f)fd.append("pdf",f);
|
| 465 |
+
fd.append("ident",ident);fd.append("title",$("#up-title").value.trim());fd.append("venue",$("#up-venue").value.trim());
|
| 466 |
+
fd.append("add_to_pool",$("#up-pool").checked?"true":"false");
|
| 467 |
+
$("#up-go").disabled=true;$("#up-err").textContent="Uploading…";
|
| 468 |
+
try{
|
| 469 |
+
const r=await fetch("/api/admin/upload",{method:"POST",body:fd});
|
| 470 |
+
if(!r.ok){const t=await r.json().catch(()=>({}));throw new Error(t.detail||r.status)}
|
| 471 |
+
$("#up-err").textContent="";$("#up-pdf").value="";$("#up-ident").value="";$("#up-title").value="";$("#up-venue").value="";
|
| 472 |
+
}catch(err){$("#up-err").textContent=String(err.message||err)}
|
| 473 |
+
$("#up-go").disabled=false;pollJobs();
|
| 474 |
+
};
|
| 475 |
+
async function pollJobs(){
|
| 476 |
+
clearTimeout(UP.timer);
|
| 477 |
+
let jobs=[];try{jobs=(await api("/api/admin/jobs")).jobs}catch(e){return}
|
| 478 |
+
const ST={queued:"waiting",running:"converting…",done:"added",error:"failed"};
|
| 479 |
+
$("#up-jobs").innerHTML=jobs.map(j=>`<div class="job"><span class="st ${j.status}">${ST[j.status]||j.status}</span> · ${esc(j.file||j.ident||"")}${j.pid?` → <b>${esc(j.pid)}</b>`:""}${j.error?`<div style="color:var(--red);margin-top:4px">${esc(j.error)}</div>`:""}<pre>${esc((j.log||[]).join("\n"))}</pre></div>`).join("");
|
| 480 |
+
const busy=jobs.some(j=>j.status==="queued"||j.status==="running");
|
| 481 |
+
const fresh=jobs.filter(j=>j.status==="done"&&!(j.pid in S.manifest.papers));
|
| 482 |
+
if(fresh.length){S.manifest=await api("/api/papers");await loadOverview()}
|
| 483 |
+
if(busy||$("#upload").style.display==="flex")UP.timer=setTimeout(pollJobs,busy?2000:6000);
|
| 484 |
+
}
|
| 485 |
+
$("#hm-q").oninput=e=>{H.q=e.target.value;renderGrid()};
|
| 486 |
+
$("#hm-venues").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.venue=p.dataset.venue;$$("#hm-venues .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()};
|
| 487 |
+
$("#hm-status").onclick=e=>{const p=e.target.closest(".pill");if(!p)return;H.status=p.dataset.status;$$("#hm-status .pill").forEach(x=>x.classList.toggle("on",x===p));renderGrid()};
|
| 488 |
+
$("#hm-annotator").onchange=e=>{H.view=e.target.value;loadOverview()};
|
| 489 |
+
|
| 490 |
+
/* ---------------- detail: PDF preview + annotation cards ---------------- */
|
| 491 |
+
async function openDetail(pid){
|
| 492 |
+
H.pid=pid;const e=S.manifest.papers[pid];
|
| 493 |
+
$("#dt-meta").innerHTML=`<span class="venue">${esc(e.venue||"")}</span><span>${esc(pid)}</span>`;
|
| 494 |
+
$("#dt-title").textContent=e.title||pid;
|
| 495 |
+
const url=`/api/paper/${enc(pid)}/pdf`;
|
| 496 |
+
$("#dt-pdf").innerHTML=e.pdf?`<iframe src="${url}#navpanes=0&view=FitH"></iframe>`:`<div class="nopdf">No PDF for this paper</div>`;
|
| 497 |
+
$("#dt-pdflink").style.display=e.pdf?"inline":"none";$("#dt-pdflink").href=url;
|
| 498 |
+
$("#dt-cards").innerHTML=`<div class="muted">Loading…</div>`;
|
| 499 |
+
$("#detail").style.display="flex";
|
| 500 |
+
const st=await api(`/api/state?paper=${enc(pid)}&annotator=${enc(H.view)}`);
|
| 501 |
+
if(H.pid!==pid)return;
|
| 502 |
+
renderCards(st);
|
| 503 |
+
const mine=!st.read_only, started=Object.values(st.fields).some(m=>Object.values(m).some(isFilled));
|
| 504 |
+
$("#dt-start").textContent=!mine?`Annotate as ${S.me.annotator}`:started?"Continue annotating":"Start annotating";
|
| 505 |
+
}
|
| 506 |
+
function recordLine(rt,r,flds){
|
| 507 |
+
const f=flds||{},x=r.extra||{};
|
| 508 |
+
const tags=Object.entries(x).filter(([k,v])=>v&&k!=="model_fk"&&typeof v!=="object").map(([k,v])=>{const t=v===true?k:String(v);return `<span class="tag">${esc(prettyEnum(t))}</span>`}).join("");
|
| 509 |
+
let main;
|
| 510 |
+
if(rt.id==="result"){main=[valOf(f.R1,50),valOf(f.R2,40)].filter(Boolean).join(" = ")}
|
| 511 |
+
else if(rt.id==="claim"){main=valOf(f.F1,160)}
|
| 512 |
+
else if(rt.id==="asset"){main=[x.asset_type&&prettyEnum(x.asset_type),valOf(f.availability_claim,90)].filter(Boolean).join(": ")}
|
| 513 |
+
else{main=valOf(f[rt.label_field],80)}
|
| 514 |
+
const extra=rt.id==="dataset"&&f.D3?`<span class="k">source</span>${esc(valOf(f.D3,40))}`:rt.id==="split"&&f.D26?`<span class="k">split unit</span>${esc(valOf(f.D26,40))}`:"";
|
| 515 |
+
const n=fieldDefs(rt).filter(d=>isFilled(f[d.id])).length;
|
| 516 |
+
return `<li>${main?esc(main):`<span class="none">(unnamed)</span>`}${rt.id==="asset"?"":tags} ${extra?"<br>"+extra:""}<span class="tag" style="float:right">${n}/${fieldDefs(rt).length}</span></li>`;
|
| 517 |
+
}
|
| 518 |
+
function renderCards(st){
|
| 519 |
+
const by={};st.records.forEach(r=>(by[r.rtype]=by[r.rtype]||[]).push(r));
|
| 520 |
+
let totalFilled=0;
|
| 521 |
+
$("#dt-cards").innerHTML=S.schema.record_types.map(rt=>{
|
| 522 |
+
const defs=fieldDefs(rt),recs=by[rt.id]||[];
|
| 523 |
+
const filledN=recs.reduce((s,r)=>s+defs.filter(d=>isFilled((st.fields[r.uid]||{})[d.id])).length,0);
|
| 524 |
+
totalFilled+=filledN;
|
| 525 |
+
const denom=defs.length*Math.max(recs.length,1),pct=Math.round(100*filledN/denom);
|
| 526 |
+
let body;
|
| 527 |
+
if(rt.cardinality==="single"){
|
| 528 |
+
const r=recs[0],f=r?(st.fields[r.uid]||{}):{};
|
| 529 |
+
const items=defs.filter(d=>d.type!=="gate_list").map(d=>`<li><span class="k">${esc(d.label)}</span>${isFilled(f[d.id])?esc(valOf(f[d.id],70)):'<span class="none">—</span>'}</li>`);
|
| 530 |
+
body=`<ul>${items.join("")}</ul>`;
|
| 531 |
+
}else{
|
| 532 |
+
body=recs.length?`<ul>${recs.map(r=>recordLine(rt,r,st.fields[r.uid])).join("")}</ul>`:`<div class="none">Not annotated yet</div>`;
|
| 533 |
+
}
|
| 534 |
+
const sub=rt.cardinality==="single"?`${filledN}/${defs.length} fields`:`${recs.length} record${recs.length===1?"":"s"} · ${filledN} fields filled`;
|
| 535 |
+
return `<div class="bcard ${rt.cardinality==="single"||rt.id==="claim"?"wide":""}" style="--bc:var(--c-${rt.id})">
|
| 536 |
+
<h4>${esc(EN_RT[rt.id]||rt.label)}</h4><div class="sub">${sub}</div>
|
| 537 |
+
<div class="bar"><i style="width:${Math.min(pct,100)}%"></i></div>${body}</div>`;
|
| 538 |
+
}).join("");
|
| 539 |
+
$("#dt-sum").textContent=(st.read_only?`${st.annotator}'s annotation (read-only) · `:"")+`${totalFilled} fields filled in total`;
|
| 540 |
+
}
|
| 541 |
+
function closeDetail(){$("#detail").style.display="none";$("#dt-pdf").innerHTML="";H.pid=null}
|
| 542 |
+
$("#dt-close").onclick=closeDetail;
|
| 543 |
+
$("#detail").onclick=e=>{if(e.target.id==="detail")closeDetail()};
|
| 544 |
+
document.addEventListener("keydown",e=>{if(e.key==="Escape"&&$("#detail").style.display==="flex")closeDetail()});
|
| 545 |
+
$("#dt-start").onclick=async()=>{
|
| 546 |
+
const pid=H.pid;closeDetail();
|
| 547 |
+
S.annotator=S.me.annotator;S.paper=pid;
|
| 548 |
+
lsSet({annotator:S.annotator,paper:pid});
|
| 549 |
+
$("#home").style.display="none";
|
| 550 |
+
await loadPaper();
|
| 551 |
+
$("#app").style.display="flex";
|
| 552 |
+
pollSync();if(!S.syncTimer)S.syncTimer=setInterval(pollSync,5000);
|
| 553 |
+
};
|
| 554 |
+
|
| 555 |
+
async function logout(){
|
| 556 |
+
await flushPendingSaves();
|
| 557 |
+
await fetch("/api/sync",{method:"POST"}).catch(()=>{});
|
| 558 |
+
await fetch("/api/logout",{method:"POST"});location.reload();
|
| 559 |
+
}
|
| 560 |
+
$("#btn-logout").onclick=logout;
|
| 561 |
+
$("#hm-logout").onclick=logout;
|
| 562 |
+
$("#btn-switch").onclick=async()=>{
|
| 563 |
+
await flushPendingSaves();
|
| 564 |
+
$("#app").style.display="none";$("#home").style.display="block";
|
| 565 |
+
H.view=S.me.annotator;if(S.me.admin)$("#hm-annotator").value=H.view;
|
| 566 |
+
await loadOverview();
|
| 567 |
+
};
|
| 568 |
+
|
| 569 |
+
/* ---------------- sync indicator ---------------- */
|
| 570 |
+
function fmtTime(ts){return new Date(ts*1000).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"})}
|
| 571 |
+
function showSync(s){
|
| 572 |
+
const el=$("#h-sync");el.classList.remove("ok","pending","err");
|
| 573 |
+
const where=s.backend==="github"?"GitHub":"disk";
|
| 574 |
+
if(s.error){el.classList.add("err");el.textContent="Sync error, retrying";el.title=s.error;return}
|
| 575 |
+
if(s.pending||Object.keys(S.saveTimers).length){el.classList.add("pending");el.textContent="Saving…";el.title="Changes are saved on the server and will be committed to "+where+" within "+s.flush_seconds+"s";return}
|
| 576 |
+
el.classList.add("ok");el.textContent=s.last_ok?"Synced to "+where+" ✓ "+fmtTime(s.last_ok):"Synced ✓";el.title="Click to sync now";
|
| 577 |
+
}
|
| 578 |
+
async function pollSync(){try{showSync(await api("/api/sync"))}catch(e){}}
|
| 579 |
+
$("#h-sync").onclick=async()=>{try{showSync(await api("/api/sync",{method:"POST"}))}catch(e){}};
|
| 580 |
+
async function flushPendingSaves(){
|
| 581 |
+
for(const [k,t] of Object.entries(S.saveTimers)){clearTimeout(t);const f=S.pendingSaves&&S.pendingSaves[k];if(f)await f();}
|
| 582 |
+
S.saveTimers={};
|
| 583 |
+
}
|
| 584 |
+
window.addEventListener("beforeunload",e=>{if(Object.keys(S.saveTimers).length){e.preventDefault();e.returnValue=""}});
|
| 585 |
+
|
| 586 |
+
async function loadPaper(){
|
| 587 |
+
const pe=enc(S.paper), entry=S.manifest.papers[S.paper];
|
| 588 |
+
const [text,state,layout]=await Promise.all([
|
| 589 |
+
fetch(`/api/paper/${pe}/text`).then(r=>r.text()),
|
| 590 |
+
api(`/api/state?paper=${pe}`),
|
| 591 |
+
fetch(`/api/paper/${pe}/layout`).then(r=>r.ok?r.json():null).catch(()=>null)
|
| 592 |
+
]);
|
| 593 |
+
S.rawText=text;S.records=state.records;S.fields=state.fields;S.layout=layout;
|
| 594 |
+
S.isMd=(entry.fulltext||"").endsWith(".md");S.rawView=false;
|
| 595 |
+
$("#h-paper").textContent=S.paper;
|
| 596 |
+
$("#h-annotator").textContent="Annotator "+S.annotator;
|
| 597 |
+
$("#h-schema").textContent="schema "+S.schema.schema_version;
|
| 598 |
+
setupLeftTabs(entry);
|
| 599 |
+
openPdf(entry.pdf?`/api/paper/${pe}/pdf`:null);
|
| 600 |
+
S.rtype=S.schema.record_types[0].id;
|
| 601 |
+
ensureSingletons();
|
| 602 |
+
renderTabs();renderRight();updateProgress();
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
async function ensureSingletons(){
|
| 606 |
+
for(const rt of S.schema.record_types.filter(r=>r.cardinality==="single")){
|
| 607 |
+
if(!S.records.some(r=>r.rtype===rt.id)){
|
| 608 |
+
const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 609 |
+
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:rt.id,extra:{}})});
|
| 610 |
+
S.records.push({uid:res.uid,rtype:rt.id,extra:{}});
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
renderTabs();renderRight();
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
/* ---------------- left pane ---------------- */
|
| 617 |
+
function esc(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}
|
| 618 |
+
function renderText(hlFrom,hlTo){
|
| 619 |
+
const el=$("#textpane");
|
| 620 |
+
el.classList.toggle("linked",!!S.layout&&!S.rawView);
|
| 621 |
+
if(S.layout&&!S.rawView){
|
| 622 |
+
/* 1003: 按 layout 块渲染, 每块记 data-b, 用来和 PDF 互相定位 */
|
| 623 |
+
el.innerHTML=S.layout.blocks.map(b=>`<div class="tb k-${b.kind}" data-b="${b.id}">${mdToHtml(S.rawText.slice(b.start,b.end))}</div>`).join("");
|
| 624 |
+
return;
|
| 625 |
+
}
|
| 626 |
+
if(S.isMd&&!S.rawView){el.innerHTML=mdToHtml(S.rawText);return}
|
| 627 |
+
if(hlFrom==null){el.innerHTML=esc(S.rawText);return}
|
| 628 |
+
el.innerHTML=esc(S.rawText.slice(0,hlFrom))+"<mark id='hl'>"+esc(S.rawText.slice(hlFrom,hlTo))+"</mark>"+esc(S.rawText.slice(hlTo));
|
| 629 |
+
document.getElementById("hl")?.scrollIntoView({block:"center",behavior:"smooth"});
|
| 630 |
+
}
|
| 631 |
+
/* 极简 md 渲染: 标题 / 管道表格 / 去粗斜体标记。只做展示, 取证坐标由后端宽容匹配回源文件 */
|
| 632 |
+
function mdToHtml(src){
|
| 633 |
+
const inline=t=>esc(t).replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/`([^`]+)`/g,"$1");
|
| 634 |
+
const lines=src.split("\n");
|
| 635 |
+
let html="",i=0,para=[];
|
| 636 |
+
const flush=()=>{if(para.length){html+="<p>"+inline(para.join(" "))+"</p>";para=[]}};
|
| 637 |
+
while(i<lines.length){
|
| 638 |
+
const L=lines[i];
|
| 639 |
+
if(/^\s*\|/.test(L)){
|
| 640 |
+
flush();
|
| 641 |
+
const rows=[];
|
| 642 |
+
while(i<lines.length&&/^\s*\|/.test(lines[i])){
|
| 643 |
+
if(!/^\s*\|[\s\-:|]+\|?\s*$/.test(lines[i]))rows.push(lines[i]);
|
| 644 |
+
i++;
|
| 645 |
+
}
|
| 646 |
+
html+="<table>"+rows.map((r,ri)=>{
|
| 647 |
+
const cells=r.replace(/^\s*\|/,"").replace(/\|\s*$/,"").split(/(?<!\\)\|/).map(c=>c.replace(/\\\|/g,"|"));
|
| 648 |
+
const tag=ri===0?"th":"td";
|
| 649 |
+
return "<tr>"+cells.map(c=>`<${tag}>`+inline(c.trim())+`</${tag}>`).join("")+"</tr>";
|
| 650 |
+
}).join("")+"</table>";
|
| 651 |
+
continue;
|
| 652 |
+
}
|
| 653 |
+
const h=L.match(/^(#{1,6})\s+(.*)/);
|
| 654 |
+
if(h){flush();const lv=Math.min(h[1].length+1,6);html+=`<h${lv}>`+inline(h[2])+`</h${lv}>`;i++;continue}
|
| 655 |
+
if(/^\s*<!--.*-->\s*$/.test(L)){i++;continue}
|
| 656 |
+
if(/^\s*$/.test(L)){flush();i++;continue}
|
| 657 |
+
para.push(L);i++;
|
| 658 |
+
}
|
| 659 |
+
flush();
|
| 660 |
+
return html;
|
| 661 |
+
}
|
| 662 |
+
/* ---------------- left: views (side by side / text / PDF / raw) ---------------- */
|
| 663 |
+
const SRC={jats:["good","Text: publisher XML (official)","Text comes from the PMC XML, not from PDF conversion"],
|
| 664 |
+
docling:["ok","Text: layout-aware PDF conversion","Converted with Docling; tables/formulas can still be wrong, check against the PDF"],
|
| 665 |
+
docling_0817:["ok","Text: PDF conversion (0817)","Docling conversion without PDF linking"],
|
| 666 |
+
pymupdf:["weak","Text: plain PDF extraction","Flat PyMuPDF text: tables are often scrambled, check against the PDF"]};
|
| 667 |
+
const W=(()=>{try{return Object.assign({split:64,single:46},JSON.parse(localStorage.getItem("repl_widths")||"{}"))}catch(e){return {split:64,single:46}}})();
|
| 668 |
+
function saveW(){try{localStorage.setItem("repl_widths",JSON.stringify(W))}catch(e){}}
|
| 669 |
+
function applyLeftWidth(){$("#left").style.width=(S.view==="split"?W.split:W.single)+"%"}
|
| 670 |
+
function setupLeftTabs(entry){
|
| 671 |
+
const views=S.layout?[["split","Side by side"],["text","Text"],["pdf","PDF"]]:[["text","Text"],["pdf","PDF"]];
|
| 672 |
+
if(S.isMd)views.push(["raw","Raw MD"]);
|
| 673 |
+
const src=SRC[entry.text_source||(S.isMd?"docling_0817":"pymupdf")]||SRC.pymupdf;
|
| 674 |
+
$("#lt-tabs").innerHTML=views.map(([k,l])=>`<div class="tab" data-lt="${k}">${l}</div>`).join("")+`<span class="src-chip ${src[0]}" title="${esc(src[2])}">${esc(src[1])}</span>`;
|
| 675 |
+
$$("#lt-tabs .tab").forEach(t=>t.onclick=()=>setView(t.dataset.lt));
|
| 676 |
+
setView(S.layout?"split":"text");
|
| 677 |
+
}
|
| 678 |
+
function setView(v){
|
| 679 |
+
S.view=v;S.rawView=(v==="raw");
|
| 680 |
+
$$("#lt-tabs .tab").forEach(x=>x.classList.toggle("on",x.dataset.lt===v));
|
| 681 |
+
$("#pdfview").style.display=(v==="split"||v==="pdf")?"block":"none";
|
| 682 |
+
$("#textpane").style.display=v==="pdf"?"none":"block";
|
| 683 |
+
applyLeftWidth();
|
| 684 |
+
if(v!=="pdf")renderText();
|
| 685 |
+
}
|
| 686 |
+
$("#splitter").onmousedown=e=>{
|
| 687 |
+
e.preventDefault();
|
| 688 |
+
const box=$("#main").getBoundingClientRect(),sp=$("#splitter");sp.classList.add("drag");
|
| 689 |
+
const key=S.view==="split"?"split":"single";
|
| 690 |
+
const mv=ev=>{W[key]=Math.min(82,Math.max(22,(ev.clientX-box.left)/box.width*100));$("#left").style.width=W[key]+"%"};
|
| 691 |
+
const up=()=>{document.removeEventListener("mousemove",mv);document.removeEventListener("mouseup",up);sp.classList.remove("drag");saveW()};
|
| 692 |
+
document.addEventListener("mousemove",mv);document.addEventListener("mouseup",up);
|
| 693 |
+
};
|
| 694 |
+
|
| 695 |
+
/* ---------------- PDF viewer (PDF.js, lazy per page; falls back to the browser viewer) ---------------- */
|
| 696 |
+
if(window.pdfjsLib)pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
| 697 |
+
const PV={doc:null,pages:[],width:0,hl:{},obs:null,token:0};
|
| 698 |
+
async function openPdf(url){
|
| 699 |
+
const box=$("#pdfview"),my=++PV.token;
|
| 700 |
+
box.innerHTML="";PV.doc=null;PV.pages=[];PV.hl={};PV.width=0;
|
| 701 |
+
if(!url){box.innerHTML=`<div class="nopdf">No PDF for this paper</div>`;return}
|
| 702 |
+
if(!window.pdfjsLib){box.style.padding="0";box.innerHTML=`<iframe src="${url}" style="width:100%;height:100%;border:0"></iframe>`;return}
|
| 703 |
+
box.style.padding="";
|
| 704 |
+
try{
|
| 705 |
+
const doc=await pdfjsLib.getDocument({url}).promise;
|
| 706 |
+
if(my!==PV.token)return;
|
| 707 |
+
for(let n=1;n<=doc.numPages;n++){const page=await doc.getPage(n);if(my!==PV.token)return;PV.pages[n]={page}}
|
| 708 |
+
PV.doc=doc;layoutPages();
|
| 709 |
+
}catch(e){box.innerHTML=`<div class="nopdf">Could not load the PDF (${esc(String(e.message||e))})</div>`}
|
| 710 |
+
}
|
| 711 |
+
function layoutPages(){
|
| 712 |
+
const box=$("#pdfview");if(!PV.doc)return;
|
| 713 |
+
const w=box.clientWidth-24;if(w<60)return; // hidden (text-only view)
|
| 714 |
+
const keep=PV.width?currentPdfSpot():null;
|
| 715 |
+
PV.width=w;
|
| 716 |
+
if(PV.obs)PV.obs.disconnect();
|
| 717 |
+
PV.obs=new IntersectionObserver(es=>es.forEach(e=>{if(e.isIntersecting)renderPage(+e.target.dataset.page)}),{root:box,rootMargin:"800px 0px"});
|
| 718 |
+
box.innerHTML="";
|
| 719 |
+
PV.pages.forEach((P,n)=>{
|
| 720 |
+
const vp=P.page.getViewport({scale:1});P.w=vp.width;P.scale=w/vp.width;P.rendered=false;
|
| 721 |
+
const d=document.createElement("div");d.className="pg";d.dataset.page=n;
|
| 722 |
+
d.style.width=w+"px";d.style.height=vp.height*P.scale+"px";
|
| 723 |
+
P.div=d;box.appendChild(d);PV.obs.observe(d);
|
| 724 |
+
});
|
| 725 |
+
if(keep)scrollPdfTo(keep.page,keep.y,false);
|
| 726 |
+
}
|
| 727 |
+
async function renderPage(n){
|
| 728 |
+
const P=PV.pages[n];if(!P||P.rendered)return;P.rendered=true;
|
| 729 |
+
const div=P.div,vp=P.page.getViewport({scale:P.scale}),dpr=window.devicePixelRatio||1;
|
| 730 |
+
const c=document.createElement("canvas");c.width=Math.floor(vp.width*dpr);c.height=Math.floor(vp.height*dpr);
|
| 731 |
+
c.style.width=vp.width+"px";c.style.height=vp.height+"px";div.appendChild(c);
|
| 732 |
+
await P.page.render({canvasContext:c.getContext("2d"),viewport:vp,transform:dpr!==1?[dpr,0,0,dpr,0,0]:undefined}).promise;
|
| 733 |
+
if(P.div!==div)return; // re-laid out meanwhile
|
| 734 |
+
div.style.setProperty("--scale-factor",P.scale);
|
| 735 |
+
const tl=document.createElement("div");tl.className="textLayer";div.appendChild(tl);
|
| 736 |
+
try{await pdfjsLib.renderTextLayer({textContentSource:P.page.streamTextContent(),container:tl,viewport:vp}).promise}catch(e){}
|
| 737 |
+
const hl=document.createElement("div");hl.className="hl-layer";div.appendChild(hl);
|
| 738 |
+
drawHl(n);
|
| 739 |
+
}
|
| 740 |
+
/* layout bbox = PDF points, top-left origin; PDF.js page width in points → css px */
|
| 741 |
+
function pageScale(n){const P=PV.pages[n];if(!P)return 1;const lp=S.layout&&S.layout.pages&&S.layout.pages[n];return PV.width/(lp?lp[0]:P.w)}
|
| 742 |
+
function currentPdfSpot(){
|
| 743 |
+
const box=$("#pdfview"),top=box.scrollTop;
|
| 744 |
+
for(const [n,P] of PV.pages.entries())if(P&&P.div&&P.div.offsetTop+P.div.offsetHeight>top)return {page:n,y:Math.max(0,top-P.div.offsetTop)/pageScale(n)};
|
| 745 |
+
return null;
|
| 746 |
+
}
|
| 747 |
+
function scrollPdfTo(n,y,smooth){
|
| 748 |
+
const P=PV.pages[n];if(!P||!P.div)return;
|
| 749 |
+
$("#pdfview").scrollTo({top:P.div.offsetTop+y*pageScale(n)-60,behavior:smooth?"smooth":"auto"});
|
| 750 |
+
}
|
| 751 |
+
function setHl(boxes,cls,scroll=true){
|
| 752 |
+
PV.hl={};(boxes||[]).forEach(b=>(PV.hl[b.page]=PV.hl[b.page]||[]).push({bbox:b.bbox,cls}));
|
| 753 |
+
PV.pages.forEach((P,n)=>{if(P&&P.rendered)drawHl(n)});
|
| 754 |
+
if(scroll&&boxes&&boxes.length&&$("#pdfview").style.display!=="none")scrollPdfTo(boxes[0].page,boxes[0].bbox[1],true);
|
| 755 |
+
}
|
| 756 |
+
function drawHl(n){
|
| 757 |
+
const P=PV.pages[n],layer=P&&P.div&&P.div.querySelector(".hl-layer");if(!layer)return;
|
| 758 |
+
const k=pageScale(n);
|
| 759 |
+
layer.innerHTML=(PV.hl[n]||[]).map(h=>{const [x0,y0,x1,y1]=h.bbox;
|
| 760 |
+
return `<i class="${h.cls}" style="left:${x0*k-2}px;top:${y0*k-2}px;width:${(x1-x0)*k+4}px;height:${(y1-y0)*k+4}px"></i>`}).join("");
|
| 761 |
+
}
|
| 762 |
+
let roT;new ResizeObserver(()=>{clearTimeout(roT);roT=setTimeout(()=>{
|
| 763 |
+
const w=$("#pdfview").clientWidth-24;if(PV.doc&&w>=60&&Math.abs(w-PV.width)>8)layoutPages()},150)}).observe($("#pdfview"));
|
| 764 |
+
|
| 765 |
+
/* ---------------- text ↔ PDF linking ---------------- */
|
| 766 |
+
function focusBlock(id,from){
|
| 767 |
+
const b=S.layout&&S.layout.blocks[id];if(!b)return;
|
| 768 |
+
$$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur"));
|
| 769 |
+
const el=$(`#textpane .tb[data-b="${id}"]`);
|
| 770 |
+
if(el){el.classList.add("cur");if(from==="pdf")el.scrollIntoView({block:"center",behavior:"smooth"})}
|
| 771 |
+
setHl(b.boxes,"blk",from!=="pdf");
|
| 772 |
+
}
|
| 773 |
+
$("#textpane").addEventListener("click",e=>{
|
| 774 |
+
if(!S.layout||S.rawView||!window.getSelection().isCollapsed)return;
|
| 775 |
+
const tb=e.target.closest(".tb");if(tb)focusBlock(+tb.dataset.b,"text");
|
| 776 |
+
});
|
| 777 |
+
$("#pdfview").addEventListener("click",e=>{
|
| 778 |
+
if(!S.layout||!window.getSelection().isCollapsed)return;
|
| 779 |
+
const pg=e.target.closest(".pg");if(!pg)return;
|
| 780 |
+
const n=+pg.dataset.page,r=pg.getBoundingClientRect(),k=pageScale(n),x=(e.clientX-r.left)/k,y=(e.clientY-r.top)/k;
|
| 781 |
+
let best=null,area=Infinity;
|
| 782 |
+
for(const b of S.layout.blocks)for(const bx of b.boxes){
|
| 783 |
+
if(bx.page!==n)continue;const [x0,y0,x1,y1]=bx.bbox;
|
| 784 |
+
if(x>=x0-3&&x<=x1+3&&y>=y0-3&&y<=y1+3){const a=(x1-x0)*(y1-y0);if(a<area){area=a;best=b}}
|
| 785 |
+
}
|
| 786 |
+
if(best)focusBlock(best.id,"pdf");
|
| 787 |
+
});
|
| 788 |
+
/* PDF selection → page boxes (PDF points), merged per line */
|
| 789 |
+
function selectionBoxes(range){
|
| 790 |
+
const pages=$$("#pdfview .pg").map(d=>[d,d.getBoundingClientRect()]),out=[];
|
| 791 |
+
for(const r of range.getClientRects()){
|
| 792 |
+
if(r.width<1||r.height<1)continue;
|
| 793 |
+
const hit=pages.find(([,b])=>r.top>=b.top-2&&r.bottom<=b.bottom+2&&r.left>=b.left-2&&r.right<=b.right+2);if(!hit)continue;
|
| 794 |
+
const [d,b]=hit,n=+d.dataset.page,k=pageScale(n);
|
| 795 |
+
const bb=[(r.left-b.left)/k,(r.top-b.top)/k,(r.right-b.left)/k,(r.bottom-b.top)/k];
|
| 796 |
+
const prev=out[out.length-1];
|
| 797 |
+
if(prev&&prev.page===n&&Math.abs(prev.bbox[1]-bb[1])<4&&bb[0]-prev.bbox[2]<30){
|
| 798 |
+
prev.bbox=[Math.min(prev.bbox[0],bb[0]),Math.min(prev.bbox[1],bb[1]),Math.max(prev.bbox[2],bb[2]),Math.max(prev.bbox[3],bb[3])];
|
| 799 |
+
}else out.push({page:n,bbox:bb});
|
| 800 |
+
}
|
| 801 |
+
return out.slice(0,60).map(o=>({page:o.page,bbox:o.bbox.map(v=>Math.round(v*10)/10)}));
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
/* selection toolbar */
|
| 805 |
+
const seltool=$("#seltool");
|
| 806 |
+
document.addEventListener("selectionchange",()=>{
|
| 807 |
+
const sel=window.getSelection();
|
| 808 |
+
if(sel.isCollapsed||!($("#textpane").contains(sel.anchorNode)||$("#pdfview").contains(sel.anchorNode))){seltool.style.display="none";return}
|
| 809 |
+
const r=sel.getRangeAt(0).getBoundingClientRect();
|
| 810 |
+
seltool.style.left=Math.min(r.left,innerWidth-160)+"px";
|
| 811 |
+
seltool.style.top=Math.max(4,r.top-34)+"px";
|
| 812 |
+
seltool.style.display="block";
|
| 813 |
+
});
|
| 814 |
+
seltool.onmousedown=e=>{e.preventDefault();addSelectionAsEvidence()};
|
| 815 |
+
document.addEventListener("keydown",e=>{
|
| 816 |
+
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="e"){e.preventDefault();addSelectionAsEvidence()}
|
| 817 |
+
});
|
| 818 |
+
function addSelectionAsEvidence(){
|
| 819 |
+
const sel=window.getSelection();
|
| 820 |
+
if(sel.isCollapsed||!sel.rangeCount)return;
|
| 821 |
+
const fromPdf=$("#pdfview").contains(sel.anchorNode);
|
| 822 |
+
let q=sel.toString();
|
| 823 |
+
if(fromPdf)q=q.replace(/([A-Za-z])-\s*\n\s*([a-z])/g,"$1$2"); // PDF line-break hyphenation
|
| 824 |
+
q=q.replace(/\s+/g," ").trim();
|
| 825 |
+
if(!q)return;
|
| 826 |
+
if(!S.active){alert("Activate a field card first");return}
|
| 827 |
+
const pl=getPayload(S.active.uid,S.active.fid);
|
| 828 |
+
pl.evidence=pl.evidence||[];
|
| 829 |
+
const ev={quote:q,section:"other",flags:{},grounded:null,char_span:null};
|
| 830 |
+
/* 在 PDF 上划选: 记下精确位置; 转换文本里对不上(损坏)时位置也不丢 */
|
| 831 |
+
if(fromPdf){ev.origin="pdf";ev.pdf_anchor={blocks:[],boxes:selectionBoxes(sel.getRangeAt(0))}}
|
| 832 |
+
pl.evidence.push(ev);
|
| 833 |
+
if(!pl.status)pl.status="stated";
|
| 834 |
+
saveField(S.active.uid,S.active.fid,pl,true);
|
| 835 |
+
sel.removeAllRanges();seltool.style.display="none";
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
/* ---------------- right pane ---------------- */
|
| 839 |
+
function rt(){return S.schema.record_types.find(r=>r.id===S.rtype)}
|
| 840 |
+
function recsOf(t){return S.records.filter(r=>r.rtype===t)}
|
| 841 |
+
function getPayload(uid,fid){
|
| 842 |
+
S.fields[uid]=S.fields[uid]||{};
|
| 843 |
+
S.fields[uid][fid]=S.fields[uid][fid]||{value:"",status:"",granularity:"",evidence:[],defer_target:"",note:""};
|
| 844 |
+
return S.fields[uid][fid];
|
| 845 |
+
}
|
| 846 |
+
function naSet(){
|
| 847 |
+
const l1=recsOf("l1")[0];if(!l1)return new Set();
|
| 848 |
+
const v=(S.fields[l1.uid]?.L1_na_fields?.value)||"";
|
| 849 |
+
return new Set(v.split(/[,,\s]+/).map(x=>x.trim().toUpperCase()).filter(Boolean));
|
| 850 |
+
}
|
| 851 |
+
function recLabel(rec,def){
|
| 852 |
+
const lf=def.label_field;
|
| 853 |
+
if(lf&&def.extras?.some(e=>e.id===lf))return rec.extra[lf]||"(unset)";
|
| 854 |
+
if(lf)return (S.fields[rec.uid]?.[lf]?.value||"").slice(0,60)||"("+lf+" empty)";
|
| 855 |
+
return def.label;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
function renderTabs(){
|
| 859 |
+
const bar=$("#rt-tabs");bar.innerHTML="";
|
| 860 |
+
S.schema.record_types.forEach(def=>{
|
| 861 |
+
const t=document.createElement("div");
|
| 862 |
+
t.className="tab"+(def.id===S.rtype?" on":"");
|
| 863 |
+
const n=recsOf(def.id).length;
|
| 864 |
+
t.innerHTML=esc(def.label)+(def.cardinality==="multi"?` <span class="cnt">(${n})</span>`:"");
|
| 865 |
+
t.onclick=()=>{S.rtype=def.id;renderTabs();renderRight()};
|
| 866 |
+
bar.appendChild(t);
|
| 867 |
+
});
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
function renderRight(){
|
| 871 |
+
const def=rt();
|
| 872 |
+
$("#rt-help").textContent=def.help||"";
|
| 873 |
+
const wrap=$("#recwrap");wrap.innerHTML="";
|
| 874 |
+
recsOf(def.id).forEach(rec=>wrap.appendChild(renderRecord(rec,def)));
|
| 875 |
+
if(def.cardinality==="multi"){
|
| 876 |
+
const bar=document.createElement("div");bar.className="addbar";
|
| 877 |
+
const b=document.createElement("button");b.className="primary";b.textContent="+ New record: "+def.label;
|
| 878 |
+
b.onclick=async()=>{
|
| 879 |
+
const extra={};(def.extras||[]).forEach(e=>{if(e.default)extra[e.id]=e.default});
|
| 880 |
+
const res=await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 881 |
+
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"create",rtype:def.id,extra})});
|
| 882 |
+
S.records.push({uid:res.uid,rtype:def.id,extra});
|
| 883 |
+
renderTabs();renderRight();
|
| 884 |
+
};
|
| 885 |
+
bar.appendChild(b);wrap.appendChild(bar);
|
| 886 |
+
}
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
function renderRecord(rec,def){
|
| 890 |
+
const card=document.createElement("div");card.className="record";
|
| 891 |
+
/* head */
|
| 892 |
+
const head=document.createElement("div");head.className="rec-head";
|
| 893 |
+
head.innerHTML=`<span class="rlabel">${esc(recLabel(rec,def))}</span><span class="uid">#${rec.uid}</span>`;
|
| 894 |
+
const ex=document.createElement("span");ex.className="extras";
|
| 895 |
+
(def.extras||[]).forEach(e=>{
|
| 896 |
+
const lab=document.createElement("label");lab.textContent=e.label+" ";
|
| 897 |
+
if(e.type==="flag"){
|
| 898 |
+
const c=document.createElement("input");c.type="checkbox";c.checked=!!rec.extra[e.id];
|
| 899 |
+
c.onchange=()=>{rec.extra[e.id]=c.checked;saveExtra(rec);renderRight()};
|
| 900 |
+
lab.prepend(c);
|
| 901 |
+
}else{
|
| 902 |
+
const s=document.createElement("select");
|
| 903 |
+
s.innerHTML="<option value=''>—</option>"+e.options.map(o=>`<option ${rec.extra[e.id]===o?"selected":""}>${o}</option>`).join("");
|
| 904 |
+
s.onchange=()=>{rec.extra[e.id]=s.value;saveExtra(rec);renderTabs();renderRight()};
|
| 905 |
+
lab.appendChild(s);
|
| 906 |
+
}
|
| 907 |
+
if(e.hint)lab.title=e.hint;
|
| 908 |
+
ex.appendChild(lab);
|
| 909 |
+
});
|
| 910 |
+
(def.extras||[]).filter(e=>e.type==="fk").forEach(()=>{});
|
| 911 |
+
head.appendChild(ex);
|
| 912 |
+
if(def.cardinality==="multi"){
|
| 913 |
+
const del=document.createElement("button");del.textContent="Delete";del.style.marginLeft="auto";
|
| 914 |
+
del.onclick=async()=>{
|
| 915 |
+
if(!confirm("Delete this record and all its field annotations?"))return;
|
| 916 |
+
await api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 917 |
+
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"delete",uid:rec.uid})});
|
| 918 |
+
S.records=S.records.filter(r=>r.uid!==rec.uid);renderTabs();renderRight();
|
| 919 |
+
};
|
| 920 |
+
head.appendChild(del);
|
| 921 |
+
}
|
| 922 |
+
card.appendChild(head);
|
| 923 |
+
/* fk-type extras (model_fk) rendered as a field-like row */
|
| 924 |
+
(def.extras||[]).filter(e=>e.type==="fk").forEach(e=>{
|
| 925 |
+
const g=document.createElement("div");g.className="field";
|
| 926 |
+
g.innerHTML=`<div class="f-head"><span class="f-id">${e.id}</span><span class="f-label">${esc(e.label)}</span></div>`;
|
| 927 |
+
g.appendChild(fkBox(e.fk_target,rec.extra[e.id]||[],ids=>{rec.extra[e.id]=ids;saveExtra(rec)}));
|
| 928 |
+
if(e.hint){const h=document.createElement("div");h.className="f-hint";h.textContent=e.hint;g.appendChild(h)}
|
| 929 |
+
card.appendChild(g);
|
| 930 |
+
});
|
| 931 |
+
/* groups */
|
| 932 |
+
const na=naSet();
|
| 933 |
+
const groups=def.groups||[{title:null,fields:def.fields}];
|
| 934 |
+
const simple=rec.extra.role&&rec.extra.role!=="proposed";
|
| 935 |
+
groups.forEach(gr=>{
|
| 936 |
+
if(gr.trigger&&!rec.extra[gr.trigger])return;
|
| 937 |
+
const g=document.createElement("div");g.className="group";
|
| 938 |
+
if(gr.title){
|
| 939 |
+
const t=document.createElement("div");t.className="group-title";
|
| 940 |
+
t.innerHTML=`<span class="tri">▾</span> ${esc(gr.title)}`;
|
| 941 |
+
t.onclick=()=>g.classList.toggle("closed");
|
| 942 |
+
g.appendChild(t);
|
| 943 |
+
if(simple&&!/M1–M4|framework/i.test(gr.title))g.classList.add("closed");
|
| 944 |
+
}
|
| 945 |
+
gr.fields.forEach(f=>g.appendChild(renderField(rec,f,na)));
|
| 946 |
+
card.appendChild(g);
|
| 947 |
+
});
|
| 948 |
+
if(simple){
|
| 949 |
+
const n=document.createElement("div");n.className="f-hint";n.style.padding="6px 12px";
|
| 950 |
+
n.textContent="role ≠ proposed: abbreviated record suffices; other groups collapsed, expand as needed.";
|
| 951 |
+
card.appendChild(n);
|
| 952 |
+
}
|
| 953 |
+
return card;
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
function fkBox(target,selected,onchange){
|
| 957 |
+
const targets=Array.isArray(target)?target:[target];
|
| 958 |
+
const box=document.createElement("div");box.className="fk-box";
|
| 959 |
+
let any=false;
|
| 960 |
+
targets.forEach(tg=>{
|
| 961 |
+
const tdef=S.schema.record_types.find(r=>r.id===tg);
|
| 962 |
+
recsOf(tg).forEach(r=>{
|
| 963 |
+
any=true;
|
| 964 |
+
const lab=document.createElement("label");
|
| 965 |
+
const c=document.createElement("input");c.type="checkbox";
|
| 966 |
+
c.checked=selected.includes(r.uid);
|
| 967 |
+
c.onchange=()=>{
|
| 968 |
+
const s=new Set(selected);c.checked?s.add(r.uid):s.delete(r.uid);
|
| 969 |
+
selected=[...s];onchange(selected);
|
| 970 |
+
};
|
| 971 |
+
lab.appendChild(c);
|
| 972 |
+
lab.append(`[${tdef.label.split("·").pop().trim()}] ${recLabel(r,tdef)} #${r.uid}`);
|
| 973 |
+
box.appendChild(lab);
|
| 974 |
+
});
|
| 975 |
+
});
|
| 976 |
+
if(!any){box.innerHTML=`<span class="muted" style="font-size:12px">No target records yet — create a ${targets.join("/")} record first</span>`}
|
| 977 |
+
return box;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
function renderField(rec,f,na){
|
| 981 |
+
const uid=rec.uid;
|
| 982 |
+
const pl=getPayload(uid,f.id);
|
| 983 |
+
const el=document.createElement("div");
|
| 984 |
+
el.className="field";el.dataset.uid=uid;el.dataset.fid=f.id;
|
| 985 |
+
const isNA=na.has(f.id.toUpperCase());
|
| 986 |
+
if(isNA)el.classList.add("na");
|
| 987 |
+
if(S.active&&S.active.uid===uid&&S.active.fid===f.id)el.classList.add("active");
|
| 988 |
+
|
| 989 |
+
const badges=[
|
| 990 |
+
f.novel?'<span class="badge b-novel">novel</span>':"",
|
| 991 |
+
f.operational?'<span class="badge b-op">operational</span>':"",
|
| 992 |
+
f.gold?'<span class="badge b-gold">gold priority</span>':"",
|
| 993 |
+
f.verbatim?'<span class="badge b-verb">verbatim</span>':"",
|
| 994 |
+
isNA?'<span class="badge b-na">L1 N/A</span>':""
|
| 995 |
+
].join("");
|
| 996 |
+
const done=pl.status?`<span class="f-done">● ${pl.status}</span>`:"";
|
| 997 |
+
el.innerHTML=`<div class="f-head"><span class="f-id">${f.id}</span><span class="f-label">${esc(f.label)}</span>${badges}${done}</div>`;
|
| 998 |
+
el.querySelector(".f-head").onclick=()=>activate(uid,f.id);
|
| 999 |
+
|
| 1000 |
+
const body=document.createElement("div");body.className="f-body";
|
| 1001 |
+
/* status */
|
| 1002 |
+
const st=document.createElement("select");
|
| 1003 |
+
st.innerHTML="<option value=''>status…</option>"+S.schema.attributes.status.map(s=>`<option value="${s.v}" ${pl.status===s.v?"selected":""}>${s.label}</option>`).join("");
|
| 1004 |
+
st.onchange=()=>{pl.status=st.value;saveField(uid,f.id,pl,true)};
|
| 1005 |
+
body.appendChild(st);
|
| 1006 |
+
/* granularity */
|
| 1007 |
+
const gr=document.createElement("select");
|
| 1008 |
+
gr.innerHTML="<option value=''>granularity…</option>"+S.schema.attributes.granularity.map(s=>`<option value="${s.v}" ${pl.granularity===s.v?"selected":""}>${s.label}</option>`).join("");
|
| 1009 |
+
gr.onchange=()=>{pl.granularity=gr.value;saveField(uid,f.id,pl)};
|
| 1010 |
+
body.appendChild(gr);
|
| 1011 |
+
/* value */
|
| 1012 |
+
body.appendChild(valueEditor(f,pl,uid));
|
| 1013 |
+
/* defer_target */
|
| 1014 |
+
if(pl.status==="absent_deferred"){
|
| 1015 |
+
const d=document.createElement("input");d.type="text";d.className="full";
|
| 1016 |
+
d.placeholder="defer_target: where it points (e.g. Appendix B.1). First confirm it is truly absent from the documents at hand.";
|
| 1017 |
+
d.value=pl.defer_target||"";
|
| 1018 |
+
d.oninput=()=>{pl.defer_target=d.value;saveField(uid,f.id,pl)};
|
| 1019 |
+
body.appendChild(d);
|
| 1020 |
+
}
|
| 1021 |
+
/* hint */
|
| 1022 |
+
if(f.hint){const h=document.createElement("div");h.className="f-hint";h.textContent=f.hint;body.appendChild(h)}
|
| 1023 |
+
/* evidence */
|
| 1024 |
+
body.appendChild(evidenceList(uid,f.id,pl));
|
| 1025 |
+
/* note */
|
| 1026 |
+
const note=document.createElement("input");note.type="text";note.className="full";
|
| 1027 |
+
note.placeholder="Note: reason for uncertain, internal contradictions, ...";note.value=pl.note||"";
|
| 1028 |
+
note.style.fontSize="12px";
|
| 1029 |
+
note.oninput=()=>{pl.note=note.value;saveField(uid,f.id,pl)};
|
| 1030 |
+
body.appendChild(note);
|
| 1031 |
+
|
| 1032 |
+
el.appendChild(body);
|
| 1033 |
+
el.addEventListener("mousedown",()=>activate(uid,f.id,el),true);
|
| 1034 |
+
return el;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
function valueEditor(f,pl,uid){
|
| 1038 |
+
if(f.type==="enum_field"){
|
| 1039 |
+
const s=document.createElement("select");s.className="full";
|
| 1040 |
+
s.innerHTML="<option value=''>value…</option>"+f.options.map(o=>`<option ${pl.value===o?"selected":""}>${o}</option>`).join("");
|
| 1041 |
+
s.onchange=()=>{pl.value=s.value;saveField(uid,f.id,pl)};
|
| 1042 |
+
return s;
|
| 1043 |
+
}
|
| 1044 |
+
if(f.type==="axes"){
|
| 1045 |
+
const box=document.createElement("div");box.className="full";
|
| 1046 |
+
pl.axes=pl.axes||[];
|
| 1047 |
+
const draw=()=>{
|
| 1048 |
+
box.innerHTML="";
|
| 1049 |
+
pl.axes.forEach((ax,i)=>{
|
| 1050 |
+
const row=document.createElement("div");row.className="axes-row";
|
| 1051 |
+
const a=document.createElement("input");a.type="text";a.placeholder="axis (patient/video/image…)";a.value=ax.axis||"";
|
| 1052 |
+
a.oninput=()=>{ax.axis=a.value;saveField(uid,f.id,pl)};
|
| 1053 |
+
const s=document.createElement("select");
|
| 1054 |
+
s.innerHTML=["disjoint","overlap","unknown"].map(o=>`<option ${ax.status===o?"selected":""}>${o}</option>`).join("");
|
| 1055 |
+
s.onchange=()=>{ax.status=s.value;saveField(uid,f.id,pl)};
|
| 1056 |
+
const d=document.createElement("button");d.textContent="×";
|
| 1057 |
+
d.onclick=()=>{pl.axes.splice(i,1);saveField(uid,f.id,pl);draw()};
|
| 1058 |
+
row.append(a,s,d);box.appendChild(row);
|
| 1059 |
+
});
|
| 1060 |
+
const add=document.createElement("button");add.textContent="+ Add axis";
|
| 1061 |
+
add.onclick=()=>{pl.axes.push({axis:"",status:"unknown"});draw()};
|
| 1062 |
+
box.appendChild(add);
|
| 1063 |
+
};
|
| 1064 |
+
draw();return box;
|
| 1065 |
+
}
|
| 1066 |
+
if(f.type==="fk"||f.type==="fk2"){
|
| 1067 |
+
const box=document.createElement("div");box.className="full";
|
| 1068 |
+
pl.fk=pl.fk||[];
|
| 1069 |
+
box.appendChild(fkBox(f.type==="fk2"?f.fk_targets:f.fk_target,pl.fk,ids=>{pl.fk=ids;saveField(uid,f.id,pl)}));
|
| 1070 |
+
if(f.allow_no_anchor){
|
| 1071 |
+
const lab=document.createElement("label");lab.style.cssText="font-size:12px;display:flex;gap:4px;margin-top:4px";
|
| 1072 |
+
const c=document.createElement("input");c.type="checkbox";c.checked=!!pl.no_anchor;
|
| 1073 |
+
c.onchange=()=>{pl.no_anchor=c.checked;saveField(uid,f.id,pl)};
|
| 1074 |
+
lab.append(c,"no_anchor (explicit: anchors to no reported result)");
|
| 1075 |
+
box.appendChild(lab);
|
| 1076 |
+
}
|
| 1077 |
+
const v=document.createElement("textarea");v.placeholder="Supplementary note, e.g. M7 selection action";v.value=pl.value||"";
|
| 1078 |
+
v.style.marginTop="4px";
|
| 1079 |
+
v.oninput=()=>{pl.value=v.value;saveField(uid,f.id,pl)};
|
| 1080 |
+
box.appendChild(v);
|
| 1081 |
+
return box;
|
| 1082 |
+
}
|
| 1083 |
+
/* text (default) */
|
| 1084 |
+
const t=document.createElement("textarea");t.className="full";t.value=pl.value||"";
|
| 1085 |
+
t.placeholder=f.suggest?("e.g. "+f.suggest.join(" / ")):"Value, verbatim from paper; judgments go downstream";
|
| 1086 |
+
t.oninput=()=>{pl.value=t.value;saveField(uid,f.id,pl)};
|
| 1087 |
+
return t;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
function evidenceList(uid,fid,pl){
|
| 1091 |
+
const box=document.createElement("div");box.className="ev-list";
|
| 1092 |
+
(pl.evidence||[]).forEach((ev,i)=>{
|
| 1093 |
+
const row=document.createElement("div");row.className="ev";
|
| 1094 |
+
const hasBox=!!(ev.pdf_anchor&&ev.pdf_anchor.boxes&&ev.pdf_anchor.boxes.length);
|
| 1095 |
+
const g=ev.grounded===true?'<span class="g-ok">✓</span>'
|
| 1096 |
+
:ev.grounded===false&&ev.origin==="pdf"&&hasBox?'<span class="g-pdf" title="Selected on the PDF but not found in the converted text (likely conversion damage). The PDF location is kept; consider ticking dmg.">◐</span>'
|
| 1097 |
+
:ev.grounded===false?'<span class="g-bad" title="Grounding mismatch: quote is not an exact substring of the fulltext; verify and fix">✗</span>':"…";
|
| 1098 |
+
row.innerHTML=`${g}`;
|
| 1099 |
+
const q=document.createElement("span");q.className="q";q.textContent=ev.quote;q.title="Click to locate in the left pane";
|
| 1100 |
+
q.onclick=()=>locateQuote(ev);
|
| 1101 |
+
row.appendChild(q);
|
| 1102 |
+
if(hasBox&&PV.doc){const t=document.createElement("span");t.className="pdf-tag";t.textContent="PDF p."+ev.pdf_anchor.boxes[0].page;
|
| 1103 |
+
t.title="Show on the PDF";t.onclick=()=>{if(S.view==="text")setView("split");setHl(ev.pdf_anchor.boxes,"ev")};row.appendChild(t)}
|
| 1104 |
+
const sec=document.createElement("select");
|
| 1105 |
+
sec.innerHTML=S.schema.attributes.evidence_sections.map(s=>`<option ${ev.section===s?"selected":""}>${s}</option>`).join("");
|
| 1106 |
+
sec.onchange=()=>{ev.section=sec.value;saveField(uid,fid,pl)};
|
| 1107 |
+
row.appendChild(sec);
|
| 1108 |
+
S.schema.attributes.evidence_flags.forEach(fl=>{
|
| 1109 |
+
const lab=document.createElement("label");lab.title=fl.label;
|
| 1110 |
+
const c=document.createElement("input");c.type="checkbox";c.checked=!!(ev.flags&&ev.flags[fl.v]);
|
| 1111 |
+
c.onchange=()=>{ev.flags=ev.flags||{};ev.flags[fl.v]=c.checked;saveField(uid,fid,pl)};
|
| 1112 |
+
lab.append(c,fl.v.replace("pipeline_damage","dmg").replace("figure_only","fig"));
|
| 1113 |
+
row.appendChild(lab);
|
| 1114 |
+
});
|
| 1115 |
+
const d=document.createElement("button");d.textContent="×";
|
| 1116 |
+
d.onclick=()=>{pl.evidence.splice(i,1);saveField(uid,fid,pl,true)};
|
| 1117 |
+
row.appendChild(d);
|
| 1118 |
+
box.appendChild(row);
|
| 1119 |
+
});
|
| 1120 |
+
const add=document.createElement("button");
|
| 1121 |
+
add.textContent="+ Figure/table evidence (no selection)";
|
| 1122 |
+
add.style.cssText="font-size:11px;align-self:flex-start";
|
| 1123 |
+
add.title="Use when the value only exists in a figure or damaged table: paste the caption verbatim (grounds if it matches) or a locator like Figure 3 left panel";
|
| 1124 |
+
add.onclick=()=>{
|
| 1125 |
+
const q=prompt("Figure/table evidence: paste the caption/table title verbatim if possible (it can then be grounded), otherwise a locator description, e.g. Figure 3 left panel");
|
| 1126 |
+
if(!q)return;
|
| 1127 |
+
pl.evidence=pl.evidence||[];
|
| 1128 |
+
pl.evidence.push({quote:q.replace(/\s+/g," ").trim(),section:"figure",flags:{figure_only:true},grounded:null,char_span:null});
|
| 1129 |
+
if(!pl.status)pl.status="stated";
|
| 1130 |
+
saveField(uid,fid,pl,true);
|
| 1131 |
+
};
|
| 1132 |
+
box.appendChild(add);
|
| 1133 |
+
return box;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
const normQ=t=>t.replace(/\s+/g," ").trim().toLowerCase();
|
| 1137 |
+
function locateQuote(ev){
|
| 1138 |
+
if(S.layout){
|
| 1139 |
+
const boxes=ev.pdf_anchor&&ev.pdf_anchor.boxes,ids=ev.pdf_anchor&&ev.pdf_anchor.blocks;
|
| 1140 |
+
if(S.view==="pdf"&&boxes&&boxes.length){setHl(boxes,"ev");return}
|
| 1141 |
+
if(S.view==="pdf"||S.view==="raw")setView("split");
|
| 1142 |
+
const fq=normQ(ev.quote);
|
| 1143 |
+
let el=ids&&ids.length?$(`#textpane .tb[data-b="${ids[0]}"]`):null;
|
| 1144 |
+
if(!el)el=$$("#textpane .tb").find(b=>normQ(b.textContent).includes(fq))||null;
|
| 1145 |
+
if(el){
|
| 1146 |
+
$$("#textpane .tb.cur").forEach(x=>x.classList.remove("cur"));el.classList.add("cur");
|
| 1147 |
+
el.scrollIntoView({block:"center",behavior:"smooth"});el.classList.add("flash");setTimeout(()=>el.classList.remove("flash"),2500);
|
| 1148 |
+
}
|
| 1149 |
+
const bx=boxes&&boxes.length?boxes:el?S.layout.blocks[+el.dataset.b].boxes:null;
|
| 1150 |
+
if(bx)setHl(bx,"ev");
|
| 1151 |
+
if(!el&&!bx)alert("Not found in the text or on the PDF. char_span (folded coords) = "+JSON.stringify(ev.char_span));
|
| 1152 |
+
return;
|
| 1153 |
+
}
|
| 1154 |
+
if(S.view==="pdf")setView("text");
|
| 1155 |
+
if(S.isMd){
|
| 1156 |
+
const fq=ev.quote.replace(/\s+/g," ").trim().toLowerCase();
|
| 1157 |
+
for(const b of $("#textpane").querySelectorAll("p,h2,h3,h4,h5,h6,tr")){
|
| 1158 |
+
if(b.textContent.replace(/\s+/g," ").toLowerCase().includes(fq)){
|
| 1159 |
+
b.scrollIntoView({block:"center",behavior:"smooth"});
|
| 1160 |
+
b.classList.add("flash");setTimeout(()=>b.classList.remove("flash"),2500);
|
| 1161 |
+
return;
|
| 1162 |
+
}
|
| 1163 |
+
}
|
| 1164 |
+
setView("raw"); // 渲染视图找不到, 退到源文件视图按原逻辑高亮
|
| 1165 |
+
}
|
| 1166 |
+
const raw=S.rawText, q=ev.quote;
|
| 1167 |
+
let i=raw.indexOf(q);
|
| 1168 |
+
if(i<0){ /* whitespace-insensitive fallback */
|
| 1169 |
+
const pat=q.trim().split(/\s+/).map(w=>w.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("\\s+");
|
| 1170 |
+
const m=raw.match(new RegExp(pat));
|
| 1171 |
+
if(m){renderText(m.index,m.index+m[0].length);return}
|
| 1172 |
+
alert("Not found in the displayed text. char_span (folded coords) = "+JSON.stringify(ev.char_span));return;
|
| 1173 |
+
}
|
| 1174 |
+
renderText(i,i+q.length);
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
/* ---------------- save ---------------- */
|
| 1178 |
+
function activate(uid,fid,el){
|
| 1179 |
+
const prev=S.active;
|
| 1180 |
+
S.active={uid,fid};
|
| 1181 |
+
if(!prev||prev.uid!==uid||prev.fid!==fid){
|
| 1182 |
+
$$(".field.active").forEach(x=>x.classList.remove("active"));
|
| 1183 |
+
(el||document.querySelector(`.field[data-uid="${uid}"][data-fid="${fid}"]`))?.classList.add("active");
|
| 1184 |
+
}
|
| 1185 |
+
}
|
| 1186 |
+
function saveExtra(rec){
|
| 1187 |
+
api("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 1188 |
+
body:JSON.stringify({annotator:S.annotator,paper:S.paper,action:"extra",uid:rec.uid,extra:rec.extra})});
|
| 1189 |
+
}
|
| 1190 |
+
function saveField(uid,fid,pl,rerender){
|
| 1191 |
+
const key=uid+"."+fid;
|
| 1192 |
+
clearTimeout(S.saveTimers[key]);
|
| 1193 |
+
S.pendingSaves=S.pendingSaves||{};
|
| 1194 |
+
const run=S.pendingSaves[key]=async()=>{
|
| 1195 |
+
delete S.saveTimers[key];delete S.pendingSaves[key];
|
| 1196 |
+
const res=await api("/api/field",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 1197 |
+
body:JSON.stringify({paper:S.paper,record_uid:uid,field_id:fid,payload:pl})});
|
| 1198 |
+
S.fields[uid][fid]=res.payload;
|
| 1199 |
+
updateProgress();
|
| 1200 |
+
if(rerender)renderRight();
|
| 1201 |
+
pollSync();
|
| 1202 |
+
};
|
| 1203 |
+
S.saveTimers[key]=setTimeout(run,rerender?0:600);
|
| 1204 |
+
const chip=$("#h-sync");chip.classList.remove("ok","err");chip.classList.add("pending");chip.textContent="Saving…";
|
| 1205 |
+
}
|
| 1206 |
+
function updateProgress(){
|
| 1207 |
+
let n=0;
|
| 1208 |
+
Object.values(S.fields).forEach(m=>Object.values(m).forEach(p=>{
|
| 1209 |
+
if(isFilled(p))n++;
|
| 1210 |
+
}));
|
| 1211 |
+
$("#h-progress").textContent=n+" fields annotated";
|
| 1212 |
+
}
|
| 1213 |
+
</script>
|
| 1214 |
+
</body>
|
| 1215 |
+
</html>
|
app/store.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""存储层: 标注数据 + 论文语料, 两种后端同一接口。
|
| 2 |
+
|
| 3 |
+
- local: 标注存 03_annotation/data/store/<annotator>/annotations/<paper>.json, 论文按 manifest 读本地语料树(开发 / 离线用)
|
| 4 |
+
- github: 默认一个私有仓库管所有(0925 定): papers/ 放论文, annotations/<annotator>/ 放各人标注。
|
| 5 |
+
服务器用一个 token(Rong 的 fine-grained PAT)读写, 标注人不需要 GitHub 账号, 只能经服务器读写自己那份。
|
| 6 |
+
(也支持每人一个仓库: ANNOTATORS_JSON 里给某人配 "repo", 其标注写进那个仓库的 annotations/。)
|
| 7 |
+
|
| 8 |
+
标注文件 = 一篇论文 × 一个标注人一份 JSON(records + fields + events), 每次同步一个 commit,
|
| 9 |
+
git 历史本身就是第二层审计痕迹。
|
| 10 |
+
"""
|
| 11 |
+
import base64
|
| 12 |
+
import hashlib
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Dict, List, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
import httpx
|
| 21 |
+
|
| 22 |
+
GITHUB_API = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------- 标注仓库 ----------
|
| 26 |
+
|
| 27 |
+
class LocalAnnotationStore:
|
| 28 |
+
def __init__(self, root: Path):
|
| 29 |
+
self.root = root
|
| 30 |
+
|
| 31 |
+
def _path(self, annotator: str, name: str) -> Path:
|
| 32 |
+
return self.root / annotator / "annotations" / name
|
| 33 |
+
|
| 34 |
+
def read(self, annotator: str, name: str) -> Tuple[Optional[bytes], Optional[str]]:
|
| 35 |
+
p = self._path(annotator, name)
|
| 36 |
+
if not p.exists():
|
| 37 |
+
return None, None
|
| 38 |
+
data = p.read_bytes()
|
| 39 |
+
return data, str(p.stat().st_mtime_ns)
|
| 40 |
+
|
| 41 |
+
def write(self, annotator: str, name: str, data: bytes, sha: Optional[str], message: str) -> str:
|
| 42 |
+
p = self._path(annotator, name)
|
| 43 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
| 45 |
+
tmp.write_bytes(data)
|
| 46 |
+
tmp.replace(p)
|
| 47 |
+
return str(p.stat().st_mtime_ns)
|
| 48 |
+
|
| 49 |
+
def list(self, annotator: str) -> List[str]:
|
| 50 |
+
d = self.root / annotator / "annotations"
|
| 51 |
+
if not d.is_dir():
|
| 52 |
+
return []
|
| 53 |
+
return sorted(f.name for f in d.iterdir() if f.suffix == ".json")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class GitHubAnnotationStore:
|
| 57 |
+
"""annotator → (仓库, 目录)。通过 Contents API 读写, 每次写一个 commit。"""
|
| 58 |
+
|
| 59 |
+
def __init__(self, token: str, locations: Dict[str, Tuple[str, str]], branch: Optional[str] = None,
|
| 60 |
+
client: Optional[httpx.Client] = None):
|
| 61 |
+
self.locations = locations
|
| 62 |
+
self.branch = branch
|
| 63 |
+
self.client = client or httpx.Client(base_url=GITHUB_API, timeout=30)
|
| 64 |
+
self.headers = {"Authorization": f"Bearer {token}",
|
| 65 |
+
"Accept": "application/vnd.github+json",
|
| 66 |
+
"X-GitHub-Api-Version": "2022-11-28"}
|
| 67 |
+
|
| 68 |
+
def _loc(self, annotator: str) -> Tuple[str, str]:
|
| 69 |
+
if annotator not in self.locations:
|
| 70 |
+
raise KeyError(f"no storage location for annotator {annotator}")
|
| 71 |
+
return self.locations[annotator]
|
| 72 |
+
|
| 73 |
+
def _url(self, annotator: str, name: str = "") -> str:
|
| 74 |
+
repo, directory = self._loc(annotator)
|
| 75 |
+
return f"/repos/{repo}/contents/{directory}" + (f"/{name}" if name else "")
|
| 76 |
+
|
| 77 |
+
def read(self, annotator: str, name: str) -> Tuple[Optional[bytes], Optional[str]]:
|
| 78 |
+
params = {"ref": self.branch} if self.branch else None
|
| 79 |
+
r = self.client.get(self._url(annotator, name),
|
| 80 |
+
headers=self.headers, params=params)
|
| 81 |
+
if r.status_code == 404:
|
| 82 |
+
return None, None
|
| 83 |
+
r.raise_for_status()
|
| 84 |
+
meta = r.json()
|
| 85 |
+
if meta.get("encoding") == "base64" and meta.get("content"):
|
| 86 |
+
return base64.b64decode(meta["content"]), meta["sha"]
|
| 87 |
+
# >1MB 的文件 content 为空, 走 raw
|
| 88 |
+
raw = self.client.get(self._url(annotator, name),
|
| 89 |
+
headers={**self.headers, "Accept": "application/vnd.github.raw+json"},
|
| 90 |
+
params=params)
|
| 91 |
+
raw.raise_for_status()
|
| 92 |
+
return raw.content, meta["sha"]
|
| 93 |
+
|
| 94 |
+
def write(self, annotator: str, name: str, data: bytes, sha: Optional[str], message: str) -> str:
|
| 95 |
+
body = {"message": message, "content": base64.b64encode(data).decode()}
|
| 96 |
+
if sha:
|
| 97 |
+
body["sha"] = sha
|
| 98 |
+
if self.branch:
|
| 99 |
+
body["branch"] = self.branch
|
| 100 |
+
url = self._url(annotator, name)
|
| 101 |
+
r = self.client.put(url, headers=self.headers, json=body)
|
| 102 |
+
if r.status_code in (409, 422):
|
| 103 |
+
# sha 过期(别处改过文件): 取最新 sha 重试一次; 服务器是唯一写入方, 以内存版本为准
|
| 104 |
+
_, cur = self.read(annotator, name)
|
| 105 |
+
if cur:
|
| 106 |
+
body["sha"] = cur
|
| 107 |
+
else:
|
| 108 |
+
body.pop("sha", None)
|
| 109 |
+
r = self.client.put(url, headers=self.headers, json=body)
|
| 110 |
+
r.raise_for_status()
|
| 111 |
+
return r.json()["content"]["sha"]
|
| 112 |
+
|
| 113 |
+
def list(self, annotator: str) -> List[str]:
|
| 114 |
+
params = {"ref": self.branch} if self.branch else None
|
| 115 |
+
r = self.client.get(self._url(annotator), headers=self.headers, params=params)
|
| 116 |
+
if r.status_code == 404:
|
| 117 |
+
return []
|
| 118 |
+
r.raise_for_status()
|
| 119 |
+
return sorted(e["name"] for e in r.json() if e["type"] == "file" and e["name"].endswith(".json"))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------- 论文语料 ----------
|
| 123 |
+
|
| 124 |
+
class LocalPapers:
|
| 125 |
+
"""manifest 里的路径相对 root。默认 root = 1.Reproducibility/, manifest = 03_annotation/papers/manifest.json。"""
|
| 126 |
+
|
| 127 |
+
def __init__(self, root: Path, manifest_path: Path):
|
| 128 |
+
self.root = root
|
| 129 |
+
self.manifest_path = manifest_path
|
| 130 |
+
|
| 131 |
+
@property
|
| 132 |
+
def ref(self) -> str:
|
| 133 |
+
return "local"
|
| 134 |
+
|
| 135 |
+
def manifest(self) -> dict:
|
| 136 |
+
return json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
| 137 |
+
|
| 138 |
+
def text(self, relpath: str) -> str:
|
| 139 |
+
return (self.root / relpath).read_text(encoding="utf-8", errors="ignore")
|
| 140 |
+
|
| 141 |
+
def pdf_path(self, relpath: str) -> Path:
|
| 142 |
+
return self.root / relpath
|
| 143 |
+
|
| 144 |
+
# ---- 1003: 上传论文 / 论文池(本地: 写在 manifest 旁的 uploads/ 与 pool.json)
|
| 145 |
+
|
| 146 |
+
def pool(self) -> Optional[List[str]]:
|
| 147 |
+
p = self.manifest_path.parent / "pool.json"
|
| 148 |
+
return json.loads(p.read_text(encoding="utf-8"))["papers"] if p.exists() else None
|
| 149 |
+
|
| 150 |
+
def set_pool(self, papers: List[str], message: str = ""):
|
| 151 |
+
(self.manifest_path.parent / "pool.json").write_text(
|
| 152 |
+
json.dumps({"papers": papers}, ensure_ascii=False, indent=1), encoding="utf-8")
|
| 153 |
+
|
| 154 |
+
def add_paper(self, pid: str, files: Dict[str, bytes], entry: dict, message: str = ""):
|
| 155 |
+
"""只增不改: pid 已存在就报错(全文是 char_span 坐标系, 冻结)。entry 里的文件字段用 files 的键名。"""
|
| 156 |
+
m = self.manifest()
|
| 157 |
+
if pid in m["papers"]:
|
| 158 |
+
raise FileExistsError(pid)
|
| 159 |
+
d = self.manifest_path.parent / "uploads" / pid
|
| 160 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 161 |
+
for name, data in files.items():
|
| 162 |
+
(d / name).write_bytes(data)
|
| 163 |
+
rel = lambda n: os.path.relpath(d / n, self.root) if n else None # noqa: E731
|
| 164 |
+
m["papers"][pid] = {**entry, "fulltext": rel(entry["fulltext"]), "pdf": rel(entry.get("pdf")),
|
| 165 |
+
"layout": rel(entry.get("layout"))}
|
| 166 |
+
self.manifest_path.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class GitHubPapers:
|
| 170 |
+
"""仓库里 subdir/(默认 papers/)放 manifest.json, manifest 里的路径相对 subdir。
|
| 171 |
+
启动时把 ref 钉成「最后一次改动 subdir 的 commit」: 本进程内所有读取都来自同一个快照, sha 写进每份标注,
|
| 172 |
+
保证 char_span 坐标系可追溯(md/txt 冻结纪律)。用最后改动 subdir 的 commit 而不是分支头,
|
| 173 |
+
是因为标注和论文同仓库时每次保存都会产生新 commit, 按分支头记会把"论文没变"误报成快照变了。"""
|
| 174 |
+
|
| 175 |
+
def __init__(self, token: str, repo: str, ref: str, cache_dir: Path, subdir: str = "papers",
|
| 176 |
+
client: Optional[httpx.Client] = None):
|
| 177 |
+
self.repo = repo
|
| 178 |
+
self.branch = ref
|
| 179 |
+
self.subdir = subdir.strip("/")
|
| 180 |
+
self.client = client or httpx.Client(base_url=GITHUB_API, timeout=120)
|
| 181 |
+
self.headers = {"Authorization": f"Bearer {token}",
|
| 182 |
+
"X-GitHub-Api-Version": "2022-11-28"}
|
| 183 |
+
params = {"sha": ref, "per_page": 1}
|
| 184 |
+
if self.subdir:
|
| 185 |
+
params["path"] = self.subdir
|
| 186 |
+
r = self.client.get(f"/repos/{repo}/commits", params=params,
|
| 187 |
+
headers={**self.headers, "Accept": "application/vnd.github+json"})
|
| 188 |
+
r.raise_for_status()
|
| 189 |
+
commits = r.json()
|
| 190 |
+
if not commits:
|
| 191 |
+
raise RuntimeError(f"no commits touching {self.subdir or '/'} on {repo}@{ref}")
|
| 192 |
+
self._sha = commits[0]["sha"]
|
| 193 |
+
self.cache_dir = cache_dir / self._sha[:12]
|
| 194 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
self._manifest = None
|
| 196 |
+
self._pool = False # False = 还没读; None = 仓库里没有 pool.json
|
| 197 |
+
self._text_cache: Dict[str, str] = {}
|
| 198 |
+
self._lock = threading.Lock()
|
| 199 |
+
|
| 200 |
+
@property
|
| 201 |
+
def ref(self) -> str:
|
| 202 |
+
return self._sha
|
| 203 |
+
|
| 204 |
+
def _raw(self, relpath: str) -> bytes:
|
| 205 |
+
full = f"{self.subdir}/{relpath}" if self.subdir else relpath
|
| 206 |
+
r = self.client.get(f"/repos/{self.repo}/contents/{full}",
|
| 207 |
+
headers={**self.headers, "Accept": "application/vnd.github.raw+json"},
|
| 208 |
+
params={"ref": self._sha})
|
| 209 |
+
r.raise_for_status()
|
| 210 |
+
return r.content
|
| 211 |
+
|
| 212 |
+
def manifest(self) -> dict:
|
| 213 |
+
if self._manifest is None:
|
| 214 |
+
self._manifest = json.loads(self._raw("manifest.json"))
|
| 215 |
+
return self._manifest
|
| 216 |
+
|
| 217 |
+
def text(self, relpath: str) -> str:
|
| 218 |
+
with self._lock:
|
| 219 |
+
if relpath not in self._text_cache:
|
| 220 |
+
self._text_cache[relpath] = self._raw(relpath).decode("utf-8", errors="ignore")
|
| 221 |
+
return self._text_cache[relpath]
|
| 222 |
+
|
| 223 |
+
def pdf_path(self, relpath: str) -> Path:
|
| 224 |
+
local = self.cache_dir / relpath
|
| 225 |
+
if not local.exists():
|
| 226 |
+
local.parent.mkdir(parents=True, exist_ok=True)
|
| 227 |
+
tmp = local.with_suffix(".part")
|
| 228 |
+
tmp.write_bytes(self._raw(relpath))
|
| 229 |
+
tmp.replace(local)
|
| 230 |
+
return local
|
| 231 |
+
|
| 232 |
+
# ---- 1003: 上传论文 / 论文池。一次上传 = 一个 commit(Git Data API), 之后快照 ref 前移到这个 commit:
|
| 233 |
+
# 已有论文的文件不变(只增不改), 所以旧标注的 char_span 仍有效; 各文档的 papers_refs_seen 会记下这次前移。
|
| 234 |
+
|
| 235 |
+
def _api(self, method: str, url: str, **kw):
|
| 236 |
+
r = self.client.request(method, f"/repos/{self.repo}{url}",
|
| 237 |
+
headers={**self.headers, "Accept": "application/vnd.github+json"}, **kw)
|
| 238 |
+
r.raise_for_status()
|
| 239 |
+
return r.json() if r.content else None
|
| 240 |
+
|
| 241 |
+
def _head_json(self, relpath: str):
|
| 242 |
+
"""分支最新版的某个 papers/ 文件(不是钉住的快照), 改 manifest / pool 时以它为底, 防丢更新。"""
|
| 243 |
+
full = f"{self.subdir}/{relpath}" if self.subdir else relpath
|
| 244 |
+
r = self.client.get(f"/repos/{self.repo}/contents/{full}", params={"ref": self.branch},
|
| 245 |
+
headers={**self.headers, "Accept": "application/vnd.github.raw+json"})
|
| 246 |
+
if r.status_code == 404:
|
| 247 |
+
return None
|
| 248 |
+
r.raise_for_status()
|
| 249 |
+
return json.loads(r.content)
|
| 250 |
+
|
| 251 |
+
def _commit(self, files: Dict[str, bytes], message: str):
|
| 252 |
+
with self._lock:
|
| 253 |
+
blobs = {p: self._api("POST", "/git/blobs", json={"content": base64.b64encode(b).decode(),
|
| 254 |
+
"encoding": "base64"})["sha"]
|
| 255 |
+
for p, b in files.items()}
|
| 256 |
+
for _ in range(5): # 标注同步也在往同一分支提交: 非快进就重来
|
| 257 |
+
head = self._api("GET", f"/git/ref/heads/{self.branch}")["object"]["sha"]
|
| 258 |
+
base_tree = self._api("GET", f"/git/commits/{head}")["tree"]["sha"]
|
| 259 |
+
tree = self._api("POST", "/git/trees", json={"base_tree": base_tree, "tree": [
|
| 260 |
+
{"path": (f"{self.subdir}/{p}" if self.subdir else p), "mode": "100644", "type": "blob",
|
| 261 |
+
"sha": s} for p, s in blobs.items()]})["sha"]
|
| 262 |
+
commit = self._api("POST", "/git/commits",
|
| 263 |
+
json={"message": message, "tree": tree, "parents": [head]})["sha"]
|
| 264 |
+
r = self.client.patch(f"/repos/{self.repo}/git/refs/heads/{self.branch}", json={"sha": commit},
|
| 265 |
+
headers={**self.headers, "Accept": "application/vnd.github+json"})
|
| 266 |
+
if r.status_code == 422:
|
| 267 |
+
continue
|
| 268 |
+
r.raise_for_status()
|
| 269 |
+
self._sha = commit
|
| 270 |
+
self.cache_dir = self.cache_dir.parent / commit[:12]
|
| 271 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 272 |
+
self._manifest = None
|
| 273 |
+
self._pool = False
|
| 274 |
+
return commit
|
| 275 |
+
raise RuntimeError("could not commit to GitHub (branch kept moving)")
|
| 276 |
+
|
| 277 |
+
def pool(self) -> Optional[List[str]]:
|
| 278 |
+
if self._pool is False:
|
| 279 |
+
r = self.client.get(f"/repos/{self.repo}/contents/{self.subdir}/pool.json", params={"ref": self._sha},
|
| 280 |
+
headers={**self.headers, "Accept": "application/vnd.github.raw+json"})
|
| 281 |
+
self._pool = json.loads(r.content)["papers"] if r.status_code == 200 else None
|
| 282 |
+
return self._pool
|
| 283 |
+
|
| 284 |
+
def set_pool(self, papers: List[str], message: str = "papers: update annotator pool"):
|
| 285 |
+
self._commit({"pool.json": json.dumps({"papers": papers}, ensure_ascii=False, indent=1).encode()}, message)
|
| 286 |
+
|
| 287 |
+
def add_paper(self, pid: str, files: Dict[str, bytes], entry: dict, message: str = ""):
|
| 288 |
+
m = self._head_json("manifest.json") or {"papers": {}}
|
| 289 |
+
if pid in m["papers"]:
|
| 290 |
+
raise FileExistsError(pid)
|
| 291 |
+
hashes = {n: hashlib.sha256(b).hexdigest() for n, b in files.items()}
|
| 292 |
+
rel = lambda n: f"{pid}/{n}" if n else None # noqa: E731
|
| 293 |
+
m["papers"][pid] = {**entry, "fulltext": rel(entry["fulltext"]), "pdf": rel(entry.get("pdf")),
|
| 294 |
+
"layout": rel(entry.get("layout")), "fulltext_sha256": hashes[entry["fulltext"]]}
|
| 295 |
+
out = {f"{pid}/{n}": b for n, b in files.items()}
|
| 296 |
+
out["manifest.json"] = json.dumps(m, ensure_ascii=False, indent=2).encode()
|
| 297 |
+
return self._commit(out, message or f"papers: add {pid}")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ---------- 写回缓存 ----------
|
| 301 |
+
|
| 302 |
+
class DocCache:
|
| 303 |
+
"""(annotator, paper) → 标注文档, 内存里改, 后台线程每 flush_seconds 秒把脏文档写回存储。
|
| 304 |
+
每次改动都立即在内存生效(界面零延迟); 同步状态通过 status() 暴露给前端的同步指示。"""
|
| 305 |
+
|
| 306 |
+
def __init__(self, store, flush_seconds: float = 15.0):
|
| 307 |
+
self.store = store
|
| 308 |
+
self.flush_seconds = flush_seconds
|
| 309 |
+
self.docs: Dict[Tuple[str, str], dict] = {}
|
| 310 |
+
self.shas: Dict[Tuple[str, str], Optional[str]] = {}
|
| 311 |
+
self.dirty: Dict[Tuple[str, str], int] = {} # key → 脏版本号
|
| 312 |
+
self.pending_msgs: Dict[Tuple[str, str], List[str]] = {}
|
| 313 |
+
self.lock = threading.RLock()
|
| 314 |
+
self.flush_lock = threading.Lock()
|
| 315 |
+
self.last_ok: Dict[str, float] = {}
|
| 316 |
+
self.last_error: Dict[str, Optional[str]] = {}
|
| 317 |
+
self._stop = threading.Event()
|
| 318 |
+
self._thread: Optional[threading.Thread] = None
|
| 319 |
+
|
| 320 |
+
@staticmethod
|
| 321 |
+
def path_for(paper: str) -> str:
|
| 322 |
+
return f"{paper}.json"
|
| 323 |
+
|
| 324 |
+
def get(self, annotator: str, paper: str, papers_ref: str) -> dict:
|
| 325 |
+
key = (annotator, paper)
|
| 326 |
+
with self.lock:
|
| 327 |
+
if key in self.docs:
|
| 328 |
+
return self.docs[key]
|
| 329 |
+
data, sha = self.store.read(annotator, self.path_for(paper))
|
| 330 |
+
with self.lock:
|
| 331 |
+
if key in self.docs: # 并发加载, 以先到的为准
|
| 332 |
+
return self.docs[key]
|
| 333 |
+
if data:
|
| 334 |
+
doc = json.loads(data)
|
| 335 |
+
else:
|
| 336 |
+
doc = {"paper": paper, "annotator": annotator, "papers_ref": papers_ref,
|
| 337 |
+
"created_ts": time.time(), "records": {}, "fields": {}, "events": []}
|
| 338 |
+
self.docs[key] = doc
|
| 339 |
+
self.shas[key] = sha
|
| 340 |
+
return doc
|
| 341 |
+
|
| 342 |
+
def mark_dirty(self, annotator: str, paper: str, msg: str):
|
| 343 |
+
key = (annotator, paper)
|
| 344 |
+
with self.lock:
|
| 345 |
+
self.dirty[key] = self.dirty.get(key, 0) + 1
|
| 346 |
+
self.pending_msgs.setdefault(key, []).append(msg)
|
| 347 |
+
|
| 348 |
+
def flush(self) -> None:
|
| 349 |
+
with self.flush_lock:
|
| 350 |
+
with self.lock:
|
| 351 |
+
work = [(k, v, json.dumps(self.docs[k], ensure_ascii=False, indent=1).encode("utf-8"),
|
| 352 |
+
self.pending_msgs.get(k, []))
|
| 353 |
+
for k, v in self.dirty.items()]
|
| 354 |
+
for key, version, data, msgs in work:
|
| 355 |
+
annotator, paper = key
|
| 356 |
+
n = len(msgs)
|
| 357 |
+
message = f"{annotator} · {paper} · {n} change{'s' if n != 1 else ''}\n\n" + "\n".join(msgs[:50])
|
| 358 |
+
try:
|
| 359 |
+
sha = self.store.write(annotator, self.path_for(paper), data, self.shas.get(key), message)
|
| 360 |
+
except Exception as e: # 保持脏, 下一轮重试
|
| 361 |
+
self.last_error[annotator] = f"{type(e).__name__}: {e}"[:300]
|
| 362 |
+
continue
|
| 363 |
+
with self.lock:
|
| 364 |
+
self.shas[key] = sha
|
| 365 |
+
if self.dirty.get(key) == version: # 期间没有新改动才清脏
|
| 366 |
+
self.dirty.pop(key, None)
|
| 367 |
+
self.pending_msgs.pop(key, None)
|
| 368 |
+
else:
|
| 369 |
+
self.pending_msgs[key] = self.pending_msgs.get(key, [])[n:]
|
| 370 |
+
self.last_ok[annotator] = time.time()
|
| 371 |
+
self.last_error[annotator] = None
|
| 372 |
+
|
| 373 |
+
def status(self, annotator: str) -> dict:
|
| 374 |
+
with self.lock:
|
| 375 |
+
pending = sum(1 for (a, _p) in self.dirty if a == annotator)
|
| 376 |
+
return {"pending": pending, "last_ok": self.last_ok.get(annotator),
|
| 377 |
+
"error": self.last_error.get(annotator), "flush_seconds": self.flush_seconds}
|
| 378 |
+
|
| 379 |
+
def _loop(self):
|
| 380 |
+
while not self._stop.wait(self.flush_seconds):
|
| 381 |
+
self.flush()
|
| 382 |
+
|
| 383 |
+
def start(self):
|
| 384 |
+
if self._thread is None:
|
| 385 |
+
self._thread = threading.Thread(target=self._loop, name="doc-flusher", daemon=True)
|
| 386 |
+
self._thread.start()
|
| 387 |
+
|
| 388 |
+
def stop(self):
|
| 389 |
+
self._stop.set()
|
| 390 |
+
self.flush()
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def env_flag(name: str, default: bool) -> bool:
|
| 394 |
+
v = os.environ.get(name)
|
| 395 |
+
if v is None:
|
| 396 |
+
return default
|
| 397 |
+
return v.strip().lower() in ("1", "true", "yes", "on")
|
app/textconv.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF / PMC XML → 标注系统用的「人读 md + layout.json」(1003)。
|
| 2 |
+
|
| 3 |
+
被两处使用(同一份代码):
|
| 4 |
+
- 01_collection/scripts/make_annotation_text.py 批量转语料
|
| 5 |
+
- 标注服务器的「上传论文」(main.py 以子进程调用本文件的 job 子命令, 依赖 docling/pymupdf 可以只装在转换环境里)
|
| 6 |
+
|
| 7 |
+
来源优先级: PMC 开放获取子集有 JATS XML → 用 XML(官方原文), 块的 PDF 位置在 PDF 文本层里对齐;
|
| 8 |
+
否则 → Docling(版面模型 + TableFormer, 关 OCR), 块位置直接来自 Docling。
|
| 9 |
+
layout.json: 每块 md 的 [start, end) + PDF (page, bbox), bbox 为 PDF point、左上原点, page 从 1 起。
|
| 10 |
+
|
| 11 |
+
子进程用法:
|
| 12 |
+
python textconv.py job --out DIR [--pdf X.pdf] [--ident PMCID|DOI|PMID]
|
| 13 |
+
→ DIR/fulltext.md, DIR/layout.json, DIR/paper.pdf, DIR/meta.json(source / pmcid / doi / title / log)
|
| 14 |
+
"""
|
| 15 |
+
import hashlib
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
import shutil
|
| 19 |
+
import sys
|
| 20 |
+
import unicodedata
|
| 21 |
+
import urllib.parse
|
| 22 |
+
import urllib.request
|
| 23 |
+
import xml.etree.ElementTree as ET
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
LAYOUT_VERSION = "1003"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def sha256(p: Path) -> str:
|
| 30 |
+
return hashlib.sha256(p.read_bytes()).hexdigest()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Builder:
|
| 34 |
+
"""顺序拼 md, 同时记块坐标。块之间空一行。"""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
self.parts, self.blocks, self.pos = [], [], 0
|
| 38 |
+
|
| 39 |
+
def add(self, md: str, kind: str, boxes=None, **extra):
|
| 40 |
+
md = md.strip("\n")
|
| 41 |
+
if not md.strip():
|
| 42 |
+
return None
|
| 43 |
+
if self.parts:
|
| 44 |
+
self.parts.append("\n\n")
|
| 45 |
+
self.pos += 2
|
| 46 |
+
start = self.pos
|
| 47 |
+
self.parts.append(md)
|
| 48 |
+
self.pos += len(md)
|
| 49 |
+
b = {"id": len(self.blocks), "kind": kind, "start": start, "end": self.pos,
|
| 50 |
+
"boxes": boxes or [], **extra}
|
| 51 |
+
self.blocks.append(b)
|
| 52 |
+
return b
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def md(self) -> str:
|
| 56 |
+
return "".join(self.parts) + "\n"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def one_line(t: str) -> str:
|
| 60 |
+
return re.sub(r"\s+", " ", t or "").strip()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def cell(t: str) -> str:
|
| 64 |
+
return one_line(t).replace("|", "\\|")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def grid_markdown(grid) -> str:
|
| 68 |
+
"""单元格网格 → markdown 表(合并单元格已展开成重复值, 保住列身份)。第一行当表头。"""
|
| 69 |
+
grid = [[cell(c) for c in r] for r in grid if any((c or "").strip() for c in r)]
|
| 70 |
+
if not grid:
|
| 71 |
+
return ""
|
| 72 |
+
w = max(len(r) for r in grid)
|
| 73 |
+
grid = [r + [""] * (w - len(r)) for r in grid]
|
| 74 |
+
lines = ["| " + " | ".join(grid[0]) + " |", "|" + "---|" * w]
|
| 75 |
+
return "\n".join(lines + ["| " + " | ".join(r) + " |" for r in grid[1:]])
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------- Docling (PDF only)
|
| 79 |
+
|
| 80 |
+
def convert_docling(pdf: Path):
|
| 81 |
+
from docling.datamodel.base_models import InputFormat
|
| 82 |
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
| 83 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
| 84 |
+
from docling_core.types.doc import PictureItem, TableItem
|
| 85 |
+
|
| 86 |
+
opts = PdfPipelineOptions()
|
| 87 |
+
opts.do_ocr = False # 原生电子版 PDF: 用嵌入文本, 不让 OCR 改字
|
| 88 |
+
opts.do_table_structure = True
|
| 89 |
+
conv = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)})
|
| 90 |
+
doc = conv.convert(str(pdf)).document
|
| 91 |
+
|
| 92 |
+
pages = {str(n): [round(p.size.width, 2), round(p.size.height, 2)] for n, p in doc.pages.items()}
|
| 93 |
+
|
| 94 |
+
def boxes(item):
|
| 95 |
+
out = []
|
| 96 |
+
for pv in getattr(item, "prov", None) or []:
|
| 97 |
+
h = doc.pages[pv.page_no].size.height
|
| 98 |
+
bb = pv.bbox.to_top_left_origin(page_height=h)
|
| 99 |
+
out.append({"page": pv.page_no, "bbox": [round(bb.l, 1), round(bb.t, 1), round(bb.r, 1), round(bb.b, 1)]})
|
| 100 |
+
return out
|
| 101 |
+
|
| 102 |
+
B = Builder()
|
| 103 |
+
# 归属表/图的 caption 跟着表/图输出; 正文顺序里单独遇到时跳过, 否则会出现两遍
|
| 104 |
+
owned = {c.cref for it in list(doc.tables) + list(doc.pictures) for c in it.captions}
|
| 105 |
+
for item, _lvl in doc.iterate_items(traverse_pictures=False):
|
| 106 |
+
if item.self_ref in owned:
|
| 107 |
+
continue
|
| 108 |
+
label = item.label.value
|
| 109 |
+
if isinstance(item, (TableItem, PictureItem)):
|
| 110 |
+
for cref in item.captions:
|
| 111 |
+
cap = cref.resolve(doc)
|
| 112 |
+
B.add(one_line(cap.text), "caption", boxes(cap))
|
| 113 |
+
if isinstance(item, TableItem):
|
| 114 |
+
md = grid_markdown([[c.text for c in row] for row in item.data.grid])
|
| 115 |
+
B.add(md or "[table — not recovered, see PDF]", "table", boxes(item))
|
| 116 |
+
else:
|
| 117 |
+
B.add("[figure — see PDF]", "figure", boxes(item))
|
| 118 |
+
continue
|
| 119 |
+
text = one_line(getattr(item, "text", ""))
|
| 120 |
+
if label == "text" and re.fullmatch(r"\d{1,4}", text):
|
| 121 |
+
continue # 投稿版页边行号(000 001 …), 每个都会被识别成一块
|
| 122 |
+
link = getattr(item, "hyperlink", None)
|
| 123 |
+
if link and not str(link).startswith("mailto:") and str(link) not in text:
|
| 124 |
+
text = f"{text} <{link}>" # PyMuPDF 版只剩锚词的超链接, 这里把目标补回来
|
| 125 |
+
if label == "title":
|
| 126 |
+
B.add("# " + text, "heading", boxes(item))
|
| 127 |
+
elif label == "section_header":
|
| 128 |
+
lv = min(max(getattr(item, "level", 1), 1), 4)
|
| 129 |
+
B.add("#" * (lv + 1) + " " + text, "heading", boxes(item))
|
| 130 |
+
elif label == "list_item":
|
| 131 |
+
mk = (getattr(item, "marker", "") or "").strip() # 保留原编号 "(2)" / "a." ; 圆点类统一成 "-"
|
| 132 |
+
B.add(("- " if not mk or mk in "-•·▪◦–*" else mk + " ") + text, "list", boxes(item))
|
| 133 |
+
elif label == "formula":
|
| 134 |
+
B.add(text or "[formula — not recovered, see PDF]", "formula", boxes(item))
|
| 135 |
+
elif label == "code":
|
| 136 |
+
B.add("```\n" + (getattr(item, "text", "") or "") + "\n```", "code", boxes(item))
|
| 137 |
+
elif label in ("footnote", "caption", "reference"):
|
| 138 |
+
B.add(text, label, boxes(item))
|
| 139 |
+
else:
|
| 140 |
+
B.add(text, "text", boxes(item))
|
| 141 |
+
return B, pages, {"source": "docling", "docling_version": _ver("docling")}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _ver(pkg):
|
| 145 |
+
try:
|
| 146 |
+
import importlib.metadata as m
|
| 147 |
+
return m.version(pkg)
|
| 148 |
+
except Exception:
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---------------------------------------------------------------- JATS XML (官方原文)
|
| 153 |
+
|
| 154 |
+
def tag(el):
|
| 155 |
+
return el.tag.split("}", 1)[-1]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
XLINK = "{http://www.w3.org/1999/xlink}href"
|
| 159 |
+
NESTED_BLOCKS = {"table-wrap", "fig", "fig-group", "table-wrap-group", "disp-formula", "list", "boxed-text"}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def child(el, name):
|
| 163 |
+
return el.find("{*}" + name) # {*} 同时匹配有 / 无命名空间
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def inline(el) -> str:
|
| 167 |
+
"""段落内联文本: 保留 ext-link 的 URL、行内公式(tex 或 MathML 文本)、上下标(^ _)。段内嵌的表/图/列表由调用方单独输出。"""
|
| 168 |
+
out = [el.text or ""]
|
| 169 |
+
for ch in el:
|
| 170 |
+
t = tag(ch)
|
| 171 |
+
if t in NESTED_BLOCKS:
|
| 172 |
+
pass
|
| 173 |
+
elif t in ("ext-link", "uri"):
|
| 174 |
+
txt = one_line("".join(ch.itertext()))
|
| 175 |
+
href = ch.get(XLINK) or ""
|
| 176 |
+
out.append(txt + (f" <{href}>" if href and href not in txt else ""))
|
| 177 |
+
elif t == "inline-formula":
|
| 178 |
+
tex = ch.find(".//{*}tex-math")
|
| 179 |
+
out.append(one_line(tex.text if tex is not None and tex.text else "".join(ch.itertext())))
|
| 180 |
+
elif t == "sup":
|
| 181 |
+
out.append("^" + "".join(ch.itertext()))
|
| 182 |
+
elif t == "sub":
|
| 183 |
+
out.append("_" + "".join(ch.itertext()))
|
| 184 |
+
else:
|
| 185 |
+
out.append(inline(ch))
|
| 186 |
+
out.append(ch.tail or "")
|
| 187 |
+
return "".join(out)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def jats_table(tw) -> str:
|
| 191 |
+
"""table-wrap → markdown 表; 处理 colspan / rowspan(展开成重复单元格, 保住列身份)。"""
|
| 192 |
+
rows = [tr for tr in tw.iter() if tag(tr) == "tr"]
|
| 193 |
+
grid, carry = [], {}
|
| 194 |
+
for tr in rows:
|
| 195 |
+
row, col = [], 0
|
| 196 |
+
cells = [c for c in tr if tag(c) in ("td", "th")]
|
| 197 |
+
ci = 0
|
| 198 |
+
while ci < len(cells) or col in carry:
|
| 199 |
+
if col in carry:
|
| 200 |
+
txt, left = carry[col]
|
| 201 |
+
row.append(txt)
|
| 202 |
+
if left <= 1:
|
| 203 |
+
del carry[col]
|
| 204 |
+
else:
|
| 205 |
+
carry[col] = (txt, left - 1)
|
| 206 |
+
col += 1
|
| 207 |
+
continue
|
| 208 |
+
c = cells[ci]
|
| 209 |
+
ci += 1
|
| 210 |
+
txt = one_line(inline(c)) # grid_markdown 统一转义
|
| 211 |
+
cs, rs = int(c.get("colspan", 1) or 1), int(c.get("rowspan", 1) or 1)
|
| 212 |
+
for k in range(cs):
|
| 213 |
+
row.append(txt)
|
| 214 |
+
if rs > 1:
|
| 215 |
+
carry[col + k] = (txt, rs - 1)
|
| 216 |
+
col += cs
|
| 217 |
+
grid.append(row)
|
| 218 |
+
return grid_markdown(grid)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def convert_jats(xml: Path):
|
| 222 |
+
root = ET.parse(xml).getroot()
|
| 223 |
+
B = Builder()
|
| 224 |
+
# 很多 PMC XML 把表/图集中放在 <floats-group>: 插到正文第一次引用它的段落后面, 没被引用的放正文末尾
|
| 225 |
+
fg = next((x for x in root if tag(x) == "floats-group"), None)
|
| 226 |
+
floats = {f.get("id"): f for f in (fg if fg is not None else []) if f.get("id")}
|
| 227 |
+
placed = set()
|
| 228 |
+
|
| 229 |
+
def place_floats(p):
|
| 230 |
+
for x in p.iter():
|
| 231 |
+
if tag(x) == "xref" and x.get("ref-type") in ("table", "fig"):
|
| 232 |
+
for rid in (x.get("rid") or "").split():
|
| 233 |
+
if rid in floats and rid not in placed:
|
| 234 |
+
placed.add(rid)
|
| 235 |
+
blocks_of(floats[rid], 1)
|
| 236 |
+
|
| 237 |
+
def caption_of(el):
|
| 238 |
+
lab, cap = child(el, "label"), child(el, "caption")
|
| 239 |
+
parts = [one_line(inline(lab)) if lab is not None else ""]
|
| 240 |
+
if cap is not None:
|
| 241 |
+
parts += [one_line(inline(x)) for x in cap if tag(x) in ("title", "p")]
|
| 242 |
+
return one_line(" ".join(p for p in parts if p))
|
| 243 |
+
|
| 244 |
+
def blocks_of(el, depth):
|
| 245 |
+
t = tag(el)
|
| 246 |
+
if t == "sec" or t == "app" or t == "ack":
|
| 247 |
+
title, label = child(el, "title"), child(el, "label")
|
| 248 |
+
head = one_line(" ".join(x for x in [inline(label) if label is not None else "",
|
| 249 |
+
inline(title) if title is not None else ""] if x))
|
| 250 |
+
if t == "ack" and not head:
|
| 251 |
+
head = "Acknowledgments"
|
| 252 |
+
if head:
|
| 253 |
+
B.add("#" * min(depth + 1, 5) + " " + head, "heading")
|
| 254 |
+
for ch in el:
|
| 255 |
+
if tag(ch) not in ("title", "label"):
|
| 256 |
+
blocks_of(ch, depth + 1)
|
| 257 |
+
elif t == "p":
|
| 258 |
+
B.add(one_line(inline(el)), "text")
|
| 259 |
+
for ch in el.iter():
|
| 260 |
+
if ch is not el and tag(ch) in ("table-wrap", "fig", "disp-formula", "list"):
|
| 261 |
+
blocks_of(ch, depth)
|
| 262 |
+
place_floats(el)
|
| 263 |
+
elif t == "list":
|
| 264 |
+
for li in el:
|
| 265 |
+
if tag(li) == "list-item":
|
| 266 |
+
B.add("- " + one_line(" ".join(inline(p) for p in li if tag(p) == "p")), "list")
|
| 267 |
+
elif t == "table-wrap":
|
| 268 |
+
B.add(caption_of(el), "caption")
|
| 269 |
+
for tb in el.iter():
|
| 270 |
+
if tag(tb) == "table":
|
| 271 |
+
B.add(jats_table(tb) or "[table — not in XML]", "table")
|
| 272 |
+
for fn in el.iter():
|
| 273 |
+
if tag(fn) == "table-wrap-foot":
|
| 274 |
+
B.add(one_line(inline(fn)), "footnote")
|
| 275 |
+
elif t == "fig":
|
| 276 |
+
B.add(caption_of(el), "caption")
|
| 277 |
+
B.add("[figure — see PDF]", "figure")
|
| 278 |
+
elif t == "disp-formula":
|
| 279 |
+
tex = el.find(".//{*}tex-math")
|
| 280 |
+
B.add(one_line(tex.text if tex is not None and tex.text else "".join(el.itertext())) or
|
| 281 |
+
"[formula — see PDF]", "formula")
|
| 282 |
+
elif t in ("table-wrap-group", "fig-group", "boxed-text"):
|
| 283 |
+
for ch in el:
|
| 284 |
+
blocks_of(ch, depth)
|
| 285 |
+
elif t == "fn-group":
|
| 286 |
+
for fn in el:
|
| 287 |
+
B.add(one_line(" ".join(inline(p) for p in fn.iter() if tag(p) == "p")), "footnote")
|
| 288 |
+
elif t == "supplementary-material":
|
| 289 |
+
href = el.get(XLINK) or ""
|
| 290 |
+
B.add(one_line(caption_of(el) + (f" <{href}>" if href else "")) or "[supplementary material]",
|
| 291 |
+
"supplementary")
|
| 292 |
+
elif t == "ref-list":
|
| 293 |
+
B.add("## References", "heading")
|
| 294 |
+
for r in el.iter():
|
| 295 |
+
if tag(r) == "ref":
|
| 296 |
+
c = next((x for x in r if tag(x) in ("mixed-citation", "element-citation", "citation")), None)
|
| 297 |
+
lab = child(r, "label")
|
| 298 |
+
txt = one_line(" ".join(c.itertext())) if c is not None else ""
|
| 299 |
+
B.add(one_line(((lab.text or "") + " " if lab is not None else "") + txt), "reference")
|
| 300 |
+
elif t in ("title", "label"):
|
| 301 |
+
pass
|
| 302 |
+
else:
|
| 303 |
+
for ch in el:
|
| 304 |
+
blocks_of(ch, depth)
|
| 305 |
+
|
| 306 |
+
front = next((x for x in root if tag(x) == "front"), None)
|
| 307 |
+
if front is not None:
|
| 308 |
+
title = next((x for x in front.iter() if tag(x) == "article-title"), None)
|
| 309 |
+
if title is not None:
|
| 310 |
+
B.add("# " + one_line(inline(title)), "heading")
|
| 311 |
+
for ab in front.iter():
|
| 312 |
+
if tag(ab) == "abstract":
|
| 313 |
+
B.add("## Abstract" + (f" ({ab.get('abstract-type')})" if ab.get("abstract-type") else ""), "heading")
|
| 314 |
+
for ch in ab:
|
| 315 |
+
blocks_of(ch, 2)
|
| 316 |
+
body = next((x for x in root if tag(x) == "body"), None)
|
| 317 |
+
if body is not None:
|
| 318 |
+
for ch in body:
|
| 319 |
+
blocks_of(ch, 1)
|
| 320 |
+
for rid, f in floats.items():
|
| 321 |
+
if rid not in placed:
|
| 322 |
+
placed.add(rid)
|
| 323 |
+
blocks_of(f, 1)
|
| 324 |
+
back = next((x for x in root if tag(x) == "back"), None)
|
| 325 |
+
if back is not None:
|
| 326 |
+
for ch in back:
|
| 327 |
+
blocks_of(ch, 1)
|
| 328 |
+
return B, {"source": "jats", "xml_sha256": sha256(xml)}
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ---------------------------------------------------------------- 对齐: md 块 → PDF 文本层
|
| 332 |
+
|
| 333 |
+
def norm_chars(s: str):
|
| 334 |
+
"""NFKC + 小写 + 只留字母数字: 对断词连字符、空格、连字(fi)都不敏感。"""
|
| 335 |
+
s = unicodedata.normalize("NFKC", s).lower()
|
| 336 |
+
return "".join(ch for ch in s if ch.isalnum())
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def align_to_pdf(B: Builder, md: str, pdf: Path):
|
| 340 |
+
import pymupdf as fitz
|
| 341 |
+
doc = fitz.open(pdf)
|
| 342 |
+
pages = {}
|
| 343 |
+
stream, owner = [], [] # 全文归一化字符流; 每个字符属于哪个 word
|
| 344 |
+
words = []
|
| 345 |
+
for pno, page in enumerate(doc, start=1):
|
| 346 |
+
pages[str(pno)] = [round(page.rect.width, 2), round(page.rect.height, 2)]
|
| 347 |
+
for w in page.get_text("words"):
|
| 348 |
+
x0, y0, x1, y1, txt, blk, ln, _ = w
|
| 349 |
+
n = norm_chars(txt)
|
| 350 |
+
if not n:
|
| 351 |
+
continue
|
| 352 |
+
wi = len(words)
|
| 353 |
+
words.append((pno, blk, (x0, y0, x1, y1)))
|
| 354 |
+
stream.append(n)
|
| 355 |
+
owner.extend([wi] * len(n))
|
| 356 |
+
doc.close()
|
| 357 |
+
S = "".join(stream)
|
| 358 |
+
cursor, hit = 0, 0
|
| 359 |
+
for b in B.blocks:
|
| 360 |
+
if b["kind"] == "figure":
|
| 361 |
+
continue
|
| 362 |
+
s = norm_chars(re.sub(r"<[^>]+>|\|---|[#|*]", " ", md[b["start"]:b["end"]]))
|
| 363 |
+
if len(s) < 8:
|
| 364 |
+
continue
|
| 365 |
+
probe = s[:40]
|
| 366 |
+
i = S.find(probe, cursor)
|
| 367 |
+
if i < 0:
|
| 368 |
+
i = S.find(probe)
|
| 369 |
+
if i < 0 and len(s) > 80: # 开头对不上(如表头次序不同)就试中段
|
| 370 |
+
mid = len(s) // 2
|
| 371 |
+
j = S.find(s[mid:mid + 40])
|
| 372 |
+
i = j - mid if j >= 0 else -1
|
| 373 |
+
if i < 0:
|
| 374 |
+
continue
|
| 375 |
+
i = max(i, 0)
|
| 376 |
+
tail = s[-40:]
|
| 377 |
+
j = S.find(tail, i, i + int(len(s) * 1.5) + 400)
|
| 378 |
+
end = j + len(tail) if j >= 0 else min(i + len(s), len(S))
|
| 379 |
+
cursor = i + 1
|
| 380 |
+
groups = {}
|
| 381 |
+
for wi in sorted(set(owner[i:end])):
|
| 382 |
+
pno, blk, (x0, y0, x1, y1) = words[wi]
|
| 383 |
+
g = groups.setdefault((pno, blk), [x0, y0, x1, y1])
|
| 384 |
+
g[0], g[1], g[2], g[3] = min(g[0], x0), min(g[1], y0), max(g[2], x1), max(g[3], y1)
|
| 385 |
+
b["boxes"] = [{"page": p, "bbox": [round(v, 1) for v in bb]} for (p, _), bb in groups.items()]
|
| 386 |
+
hit += 1
|
| 387 |
+
return pages, hit
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ---------------------------------------------------------------- 输出
|
| 391 |
+
|
| 392 |
+
def layout_dict(B: Builder, pages: dict, meta: dict, pdf) -> dict:
|
| 393 |
+
return {"layout_version": LAYOUT_VERSION, **meta, "pdf_sha256": sha256(Path(pdf)) if pdf else None,
|
| 394 |
+
"md_sha256": hashlib.sha256(B.md.encode("utf-8")).hexdigest(), "pages": pages, "blocks": B.blocks}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def md_title(B: Builder):
|
| 398 |
+
for b in B.blocks:
|
| 399 |
+
if b["kind"] == "heading":
|
| 400 |
+
t = B.md[b["start"]:b["end"]].lstrip("#").strip()
|
| 401 |
+
if len(t) > 12 and not re.match(r"^(abstract|introduction|\d+\s)", t, re.I):
|
| 402 |
+
return t[:300]
|
| 403 |
+
return None
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# ---------------------------------------------------------------- PMC: 识别 + 拉取(开放获取子集)
|
| 407 |
+
|
| 408 |
+
UA = "reproducibility-annotator/1.0 (research; mailto:none)"
|
| 409 |
+
IDCONV = "https://pmc.ncbi.nlm.nih.gov/tools/idconv/api/v1/articles/?format=json&tool=repro-annotator&ids="
|
| 410 |
+
OA = "https://pmc-oa-opendata.s3.amazonaws.com"
|
| 411 |
+
DOI_RE = re.compile(r"\b(10\.\d{4,9}/[^\s\"<>]+)", re.I)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _get(url: str, timeout=60) -> bytes:
|
| 415 |
+
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
| 416 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 417 |
+
return r.read()
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def to_pmcid(ident: str, log):
|
| 421 |
+
"""PMCID / DOI / PMID → PMCID(查不到返回 None)。"""
|
| 422 |
+
s = ident.strip()
|
| 423 |
+
m = re.fullmatch(r"(?i)(pmc)?(\d{5,9})", s)
|
| 424 |
+
if m and m.group(1):
|
| 425 |
+
return "PMC" + m.group(2)
|
| 426 |
+
s = re.sub(r"(?i)^(https?://(dx\.)?doi\.org/|doi:\s*)", "", s)
|
| 427 |
+
try:
|
| 428 |
+
recs = json.loads(_get(IDCONV + urllib.parse.quote(s, safe="")))["records"]
|
| 429 |
+
except Exception as e: # noqa: BLE001
|
| 430 |
+
log(f"PMC ID lookup failed for {s}: {e}")
|
| 431 |
+
return None
|
| 432 |
+
pmcid = next((r.get("pmcid") for r in recs if r.get("pmcid")), None)
|
| 433 |
+
log(f"{s} → {pmcid or 'not in PMC'}")
|
| 434 |
+
return pmcid
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def ident_from_pdf(pdf: Path, log):
|
| 438 |
+
"""PDF 前两页里找 PMCID(作者稿常印在页眉)或 DOI。"""
|
| 439 |
+
import pymupdf as fitz
|
| 440 |
+
with fitz.open(pdf) as d:
|
| 441 |
+
text = " ".join(d[i].get_text() for i in range(min(2, len(d))))
|
| 442 |
+
m = re.search(r"\bPMC\d{5,9}\b", text)
|
| 443 |
+
if m:
|
| 444 |
+
log(f"found {m.group(0)} printed in the PDF")
|
| 445 |
+
return m.group(0)
|
| 446 |
+
m = DOI_RE.search(text)
|
| 447 |
+
if m:
|
| 448 |
+
doi = m.group(1).rstrip(".,;)]")
|
| 449 |
+
log(f"found DOI {doi} in the PDF")
|
| 450 |
+
return doi
|
| 451 |
+
log("no PMCID / DOI found in the PDF")
|
| 452 |
+
return None
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def pmc_oa(pmcid: str, log):
|
| 456 |
+
"""开放获取子集里这篇的最新版本元数据(含 xml_url / pdf_url / license); 不在 OA 子集返回 None。"""
|
| 457 |
+
listing = _get(f"{OA}/?list-type=2&delimiter=/&prefix={pmcid}.").decode()
|
| 458 |
+
vers = sorted(int(v) for v in re.findall(rf"<Prefix>{pmcid}\.(\d+)/</Prefix>", listing))
|
| 459 |
+
if not vers:
|
| 460 |
+
log(f"{pmcid} is not in the PMC open-access subset (no XML available)")
|
| 461 |
+
return None
|
| 462 |
+
key = f"{pmcid}.{vers[-1]}"
|
| 463 |
+
meta = json.loads(_get(f"{OA}/{key}/{key}.json"))
|
| 464 |
+
fix = lambda u: (u or "").split("?")[0].replace("s3://pmc-oa-opendata", OA) or None # noqa: E731
|
| 465 |
+
meta["xml_url"], meta["pdf_url"] = fix(meta.get("xml_url")), fix(meta.get("pdf_url"))
|
| 466 |
+
log(f"{key}: open access, license {meta.get('license_code')}, xml={'yes' if meta['xml_url'] else 'no'}, "
|
| 467 |
+
f"pdf={'yes' if meta['pdf_url'] else 'no'}")
|
| 468 |
+
return meta
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def run_job(out: Path, pdf=None, ident=None) -> dict:
|
| 472 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 473 |
+
logs = []
|
| 474 |
+
log = lambda m: (logs.append(m), print(m, flush=True)) # noqa: E731
|
| 475 |
+
info = {"source": None, "pmcid": None, "doi": None, "title": None, "license": None}
|
| 476 |
+
pdf = Path(pdf) if pdf else None
|
| 477 |
+
|
| 478 |
+
guess = ident or (ident_from_pdf(pdf, log) if pdf else None)
|
| 479 |
+
if guess and re.match(r"10\.", re.sub(r"(?i)^(https?://(dx\.)?doi\.org/|doi:\s*)", "", guess.strip())):
|
| 480 |
+
info["doi"] = re.sub(r"(?i)^(https?://(dx\.)?doi\.org/|doi:\s*)", "", guess.strip())
|
| 481 |
+
pmcid = to_pmcid(guess, log) if guess else None
|
| 482 |
+
oa = pmc_oa(pmcid, log) if pmcid else None
|
| 483 |
+
info["pmcid"] = pmcid
|
| 484 |
+
|
| 485 |
+
if oa and oa.get("xml_url"):
|
| 486 |
+
xml = out / "source.xml"
|
| 487 |
+
xml.write_bytes(_get(oa["xml_url"]))
|
| 488 |
+
if not pdf:
|
| 489 |
+
if not oa.get("pdf_url"):
|
| 490 |
+
raise SystemExit("PMC has XML but no PDF for this article; please upload the PDF")
|
| 491 |
+
(out / "paper.pdf").write_bytes(_get(oa["pdf_url"], timeout=180))
|
| 492 |
+
log("downloaded the PDF from PMC")
|
| 493 |
+
else:
|
| 494 |
+
shutil.copyfile(pdf, out / "paper.pdf")
|
| 495 |
+
B, meta = convert_jats(xml)
|
| 496 |
+
pages, hit = align_to_pdf(B, B.md, out / "paper.pdf")
|
| 497 |
+
log(f"PMC XML → {len(B.blocks)} blocks, {hit} located on the PDF")
|
| 498 |
+
info.update(source="jats", title=oa.get("title") or md_title(B), license=oa.get("license_code"))
|
| 499 |
+
meta["pmcid"] = pmcid
|
| 500 |
+
else:
|
| 501 |
+
if not pdf:
|
| 502 |
+
raise SystemExit("no PDF uploaded and no open-access PMC XML found")
|
| 503 |
+
shutil.copyfile(pdf, out / "paper.pdf")
|
| 504 |
+
log("converting the PDF with Docling (layout + tables)…")
|
| 505 |
+
B, pages, meta = convert_docling(out / "paper.pdf")
|
| 506 |
+
log(f"Docling → {len(pages)} pages, {len(B.blocks)} blocks")
|
| 507 |
+
info.update(source="docling", title=md_title(B))
|
| 508 |
+
|
| 509 |
+
(out / "fulltext.md").write_text(B.md, encoding="utf-8")
|
| 510 |
+
(out / "layout.json").write_text(json.dumps(layout_dict(B, pages, meta, out / "paper.pdf"), ensure_ascii=False),
|
| 511 |
+
encoding="utf-8")
|
| 512 |
+
info["log"] = logs
|
| 513 |
+
(out / "meta.json").write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
|
| 514 |
+
return info
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
if __name__ == "__main__":
|
| 518 |
+
import argparse
|
| 519 |
+
ap = argparse.ArgumentParser()
|
| 520 |
+
ap.add_argument("cmd", choices=["job"])
|
| 521 |
+
ap.add_argument("--out", required=True)
|
| 522 |
+
ap.add_argument("--pdf")
|
| 523 |
+
ap.add_argument("--ident")
|
| 524 |
+
a = ap.parse_args()
|
| 525 |
+
try:
|
| 526 |
+
run_job(Path(a.out), a.pdf, a.ident)
|
| 527 |
+
except SystemExit as e:
|
| 528 |
+
print(f"ERROR: {e}", flush=True)
|
| 529 |
+
sys.exit(2)
|