roz322 commited on
Commit
3f2c936
·
verified ·
1 Parent(s): 2498668

Annotator: side-by-side PDF view, paper upload with PMC XML

Browse files
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: red
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
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 &amp; 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
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)