File size: 20,984 Bytes
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0111e0
 
 
 
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0111e0
 
 
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0111e0
 
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0111e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0111e0
 
 
 
 
 
 
 
 
 
 
3f2c936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""存储层: 标注数据 + 论文语料, 两种后端同一接口。

- local: 标注存 03_annotation/data/store/<annotator>/annotations/<paper>.json, 论文按 manifest 读本地语料树(开发 / 离线用)
- github: 默认一个私有仓库管所有(0925 定): papers/ 放论文, annotations/<annotator>/ 放各人标注。
  服务器用一个 token(Rong 的 fine-grained PAT)读写, 标注人不需要 GitHub 账号, 只能经服务器读写自己那份。
  (也支持每人一个仓库: ANNOTATORS_JSON 里给某人配 "repo", 其标注写进那个仓库的 annotations/。)

标注文件 = 一篇论文 × 一个标注人一份 JSON(records + fields + events), 每次同步一个 commit,
git 历史本身就是第二层审计痕迹。
"""
import base64
import hashlib
import json
import os
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import httpx

GITHUB_API = os.environ.get("GITHUB_API_URL", "https://api.github.com")


class ConflictError(Exception):
    """存储里的文件已被别处改过(sha 对不上)。由 DocCache 读回合并后重写, 不直接覆盖。"""


# ---------- 标注仓库 ----------

class LocalAnnotationStore:
    def __init__(self, root: Path):
        self.root = root

    def _path(self, annotator: str, name: str) -> Path:
        return self.root / annotator / "annotations" / name

    def read(self, annotator: str, name: str) -> Tuple[Optional[bytes], Optional[str]]:
        p = self._path(annotator, name)
        if not p.exists():
            return None, None
        data = p.read_bytes()
        return data, str(p.stat().st_mtime_ns)

    def write(self, annotator: str, name: str, data: bytes, sha: Optional[str], message: str) -> str:
        p = self._path(annotator, name)
        cur = str(p.stat().st_mtime_ns) if p.exists() else None
        if cur != sha:
            raise ConflictError(name)
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp = p.with_suffix(p.suffix + ".tmp")
        tmp.write_bytes(data)
        tmp.replace(p)
        return str(p.stat().st_mtime_ns)

    def list(self, annotator: str) -> List[str]:
        d = self.root / annotator / "annotations"
        if not d.is_dir():
            return []
        return sorted(f.name for f in d.iterdir() if f.suffix == ".json")


class GitHubAnnotationStore:
    """annotator → (仓库, 目录)。通过 Contents API 读写, 每次写一个 commit。"""

    def __init__(self, token: str, locations: Dict[str, Tuple[str, str]], branch: Optional[str] = None,
                 client: Optional[httpx.Client] = None):
        self.locations = locations
        self.branch = branch
        self.client = client or httpx.Client(base_url=GITHUB_API, timeout=30)
        self.headers = {"Authorization": f"Bearer {token}",
                        "Accept": "application/vnd.github+json",
                        "X-GitHub-Api-Version": "2022-11-28"}

    def _loc(self, annotator: str) -> Tuple[str, str]:
        if annotator not in self.locations:
            raise KeyError(f"no storage location for annotator {annotator}")
        return self.locations[annotator]

    def _url(self, annotator: str, name: str = "") -> str:
        repo, directory = self._loc(annotator)
        return f"/repos/{repo}/contents/{directory}" + (f"/{name}" if name else "")

    def read(self, annotator: str, name: str) -> Tuple[Optional[bytes], Optional[str]]:
        params = {"ref": self.branch} if self.branch else None
        r = self.client.get(self._url(annotator, name),
                            headers=self.headers, params=params)
        if r.status_code == 404:
            return None, None
        r.raise_for_status()
        meta = r.json()
        if meta.get("encoding") == "base64" and meta.get("content"):
            return base64.b64decode(meta["content"]), meta["sha"]
        # >1MB 的文件 content 为空, 走 raw
        raw = self.client.get(self._url(annotator, name),
                              headers={**self.headers, "Accept": "application/vnd.github.raw+json"},
                              params=params)
        raw.raise_for_status()
        return raw.content, meta["sha"]

    def write(self, annotator: str, name: str, data: bytes, sha: Optional[str], message: str) -> str:
        body = {"message": message, "content": base64.b64encode(data).decode()}
        if sha:
            body["sha"] = sha
        if self.branch:
            body["branch"] = self.branch
        url = self._url(annotator, name)
        r = self.client.put(url, headers=self.headers, json=body)
        if r.status_code in (409, 422):
            # sha 过期(别处改过文件, 如另一个服务器实例): 不覆盖, 交给 DocCache 合并后重写
            raise ConflictError(name)
        r.raise_for_status()
        return r.json()["content"]["sha"]

    def list(self, annotator: str) -> List[str]:
        params = {"ref": self.branch} if self.branch else None
        r = self.client.get(self._url(annotator), headers=self.headers, params=params)
        if r.status_code == 404:
            return []
        r.raise_for_status()
        return sorted(e["name"] for e in r.json() if e["type"] == "file" and e["name"].endswith(".json"))


# ---------- 论文语料 ----------

class LocalPapers:
    """manifest 里的路径相对 root。默认 root = 1.Reproducibility/, manifest = 03_annotation/papers/manifest.json。"""

    def __init__(self, root: Path, manifest_path: Path):
        self.root = root
        self.manifest_path = manifest_path

    @property
    def ref(self) -> str:
        return "local"

    def manifest(self) -> dict:
        return json.loads(self.manifest_path.read_text(encoding="utf-8"))

    def text(self, relpath: str) -> str:
        return (self.root / relpath).read_text(encoding="utf-8", errors="ignore")

    def pdf_path(self, relpath: str) -> Path:
        return self.root / relpath

    # ---- 1003: 上传论文 / 论文池(本地: 写在 manifest 旁的 uploads/ 与 pool.json)

    def pool(self) -> Optional[List[str]]:
        p = self.manifest_path.parent / "pool.json"
        return json.loads(p.read_text(encoding="utf-8"))["papers"] if p.exists() else None

    def set_pool(self, papers: List[str], message: str = ""):
        (self.manifest_path.parent / "pool.json").write_text(
            json.dumps({"papers": papers}, ensure_ascii=False, indent=1), encoding="utf-8")

    def add_paper(self, pid: str, files: Dict[str, bytes], entry: dict, message: str = ""):
        """只增不改: pid 已存在就报错(全文是 char_span 坐标系, 冻结)。entry 里的文件字段用 files 的键名。"""
        m = self.manifest()
        if pid in m["papers"]:
            raise FileExistsError(pid)
        d = self.manifest_path.parent / "uploads" / pid
        d.mkdir(parents=True, exist_ok=True)
        for name, data in files.items():
            (d / name).write_bytes(data)
        rel = lambda n: os.path.relpath(d / n, self.root) if n else None  # noqa: E731
        m["papers"][pid] = {**entry, "fulltext": rel(entry["fulltext"]), "pdf": rel(entry.get("pdf")),
                            "layout": rel(entry.get("layout"))}
        self.manifest_path.write_text(json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")


class GitHubPapers:
    """仓库里 subdir/(默认 papers/)放 manifest.json, manifest 里的路径相对 subdir。
    启动时把 ref 钉成「最后一次改动 subdir 的 commit」: 本进程内所有读取都来自同一个快照, sha 写进每份标注,
    保证 char_span 坐标系可追溯(md/txt 冻结纪律)。用最后改动 subdir 的 commit 而不是分支头,
    是因为标注和论文同仓库时每次保存都会产生新 commit, 按分支头记会把"论文没变"误报成快照变了。"""

    def __init__(self, token: str, repo: str, ref: str, cache_dir: Path, subdir: str = "papers",
                 client: Optional[httpx.Client] = None):
        self.repo = repo
        self.branch = ref
        self.subdir = subdir.strip("/")
        self.client = client or httpx.Client(base_url=GITHUB_API, timeout=120)
        self.headers = {"Authorization": f"Bearer {token}",
                        "X-GitHub-Api-Version": "2022-11-28"}
        params = {"sha": ref, "per_page": 1}
        if self.subdir:
            params["path"] = self.subdir
        r = self.client.get(f"/repos/{repo}/commits", params=params,
                            headers={**self.headers, "Accept": "application/vnd.github+json"})
        r.raise_for_status()
        commits = r.json()
        if not commits:
            raise RuntimeError(f"no commits touching {self.subdir or '/'} on {repo}@{ref}")
        self._sha = commits[0]["sha"]
        self.cache_dir = cache_dir / self._sha[:12]
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self._manifest = None
        self._pool = False                       # False = 还没读; None = 仓库里没有 pool.json
        self._text_cache: Dict[str, str] = {}
        self._lock = threading.Lock()

    @property
    def ref(self) -> str:
        return self._sha

    def _raw(self, relpath: str) -> bytes:
        full = f"{self.subdir}/{relpath}" if self.subdir else relpath
        r = self.client.get(f"/repos/{self.repo}/contents/{full}",
                            headers={**self.headers, "Accept": "application/vnd.github.raw+json"},
                            params={"ref": self._sha})
        r.raise_for_status()
        return r.content

    def manifest(self) -> dict:
        if self._manifest is None:
            self._manifest = json.loads(self._raw("manifest.json"))
        return self._manifest

    def text(self, relpath: str) -> str:
        with self._lock:
            if relpath not in self._text_cache:
                self._text_cache[relpath] = self._raw(relpath).decode("utf-8", errors="ignore")
            return self._text_cache[relpath]

    def pdf_path(self, relpath: str) -> Path:
        local = self.cache_dir / relpath
        if not local.exists():
            local.parent.mkdir(parents=True, exist_ok=True)
            tmp = local.with_suffix(".part")
            tmp.write_bytes(self._raw(relpath))
            tmp.replace(local)
        return local

    # ---- 1003: 上传论文 / 论文池。一次上传 = 一个 commit(Git Data API), 之后快照 ref 前移到这个 commit:
    # 已有论文的文件不变(只增不改), 所以旧标注的 char_span 仍有效; 各文档的 papers_refs_seen 会记下这次前移。

    def _api(self, method: str, url: str, **kw):
        r = self.client.request(method, f"/repos/{self.repo}{url}",
                                headers={**self.headers, "Accept": "application/vnd.github+json"}, **kw)
        r.raise_for_status()
        return r.json() if r.content else None

    def _head_json(self, relpath: str):
        """分支最新版的某个 papers/ 文件(不是钉住的快照), 改 manifest / pool 时以它为底, 防丢更新。"""
        full = f"{self.subdir}/{relpath}" if self.subdir else relpath
        r = self.client.get(f"/repos/{self.repo}/contents/{full}", params={"ref": self.branch},
                            headers={**self.headers, "Accept": "application/vnd.github.raw+json"})
        if r.status_code == 404:
            return None
        r.raise_for_status()
        return json.loads(r.content)

    def _commit(self, files: Dict[str, bytes], message: str):
        with self._lock:
            blobs = {p: self._api("POST", "/git/blobs", json={"content": base64.b64encode(b).decode(),
                                                               "encoding": "base64"})["sha"]
                     for p, b in files.items()}
            for _ in range(5):                       # 标注同步也在往同一分支提交: 非快进就重来
                head = self._api("GET", f"/git/ref/heads/{self.branch}")["object"]["sha"]
                base_tree = self._api("GET", f"/git/commits/{head}")["tree"]["sha"]
                tree = self._api("POST", "/git/trees", json={"base_tree": base_tree, "tree": [
                    {"path": (f"{self.subdir}/{p}" if self.subdir else p), "mode": "100644", "type": "blob",
                     "sha": s} for p, s in blobs.items()]})["sha"]
                commit = self._api("POST", "/git/commits",
                                   json={"message": message, "tree": tree, "parents": [head]})["sha"]
                r = self.client.patch(f"/repos/{self.repo}/git/refs/heads/{self.branch}", json={"sha": commit},
                                      headers={**self.headers, "Accept": "application/vnd.github+json"})
                if r.status_code == 422:
                    continue
                r.raise_for_status()
                self._sha = commit
                self.cache_dir = self.cache_dir.parent / commit[:12]
                self.cache_dir.mkdir(parents=True, exist_ok=True)
                self._manifest = None
                self._pool = False
                return commit
            raise RuntimeError("could not commit to GitHub (branch kept moving)")

    def pool(self) -> Optional[List[str]]:
        if self._pool is False:
            r = self.client.get(f"/repos/{self.repo}/contents/{self.subdir}/pool.json", params={"ref": self._sha},
                                headers={**self.headers, "Accept": "application/vnd.github.raw+json"})
            self._pool = json.loads(r.content)["papers"] if r.status_code == 200 else None
        return self._pool

    def set_pool(self, papers: List[str], message: str = "papers: update annotator pool"):
        self._commit({"pool.json": json.dumps({"papers": papers}, ensure_ascii=False, indent=1).encode()}, message)

    def add_paper(self, pid: str, files: Dict[str, bytes], entry: dict, message: str = ""):
        m = self._head_json("manifest.json") or {"papers": {}}
        if pid in m["papers"]:
            raise FileExistsError(pid)
        hashes = {n: hashlib.sha256(b).hexdigest() for n, b in files.items()}
        rel = lambda n: f"{pid}/{n}" if n else None  # noqa: E731
        m["papers"][pid] = {**entry, "fulltext": rel(entry["fulltext"]), "pdf": rel(entry.get("pdf")),
                            "layout": rel(entry.get("layout")), "fulltext_sha256": hashes[entry["fulltext"]]}
        out = {f"{pid}/{n}": b for n, b in files.items()}
        out["manifest.json"] = json.dumps(m, ensure_ascii=False, indent=2).encode()
        return self._commit(out, message or f"papers: add {pid}")


# ---------- 写回缓存 ----------

def merge_docs(mine: dict, theirs: dict) -> None:
    """把存储里的版本(theirs)合并进内存版本(mine), 原地修改 mine。
    记录 / 字段按 updated_ts 取较新的一份; 两边都有的保留较新; 只一边有的保留。events、papers_refs_seen 取并集。"""
    def newer(a, b):
        return a if (a or {}).get("updated_ts", 0) >= (b or {}).get("updated_ts", 0) else b

    for uid, rec in (theirs.get("records") or {}).items():
        mine.setdefault("records", {})[uid] = newer(mine["records"].get(uid), rec) if uid in mine["records"] else rec
    for uid, flds in (theirs.get("fields") or {}).items():
        mf = mine.setdefault("fields", {}).setdefault(uid, {})
        for fid, f in flds.items():
            mf[fid] = newer(mf.get(fid), f) if fid in mf else f
    seen = {(e.get("ts"), e.get("kind"), e.get("detail")) for e in mine.get("events", [])}
    extra = [e for e in theirs.get("events", []) if (e.get("ts"), e.get("kind"), e.get("detail")) not in seen]
    if extra:
        mine["events"] = sorted(mine.get("events", []) + extra, key=lambda e: e.get("ts", 0))
    refs = mine.setdefault("papers_refs_seen", [])
    refs += [r for r in theirs.get("papers_refs_seen", []) if r not in refs]
    mine["events"].append({"ts": time.time(), "kind": "merged_concurrent_copy",
                           "detail": f"{len(extra)} events from a concurrently written copy"})


class DocCache:
    """(annotator, paper) → 标注文档, 内存里改, 后台线程每 flush_seconds 秒把脏文档写回存储。
    每次改动都立即在内存生效(界面零延迟); 同步状态通过 status() 暴露给前端的同步指示。"""

    def __init__(self, store, flush_seconds: float = 15.0):
        self.store = store
        self.flush_seconds = flush_seconds
        self.docs: Dict[Tuple[str, str], dict] = {}
        self.shas: Dict[Tuple[str, str], Optional[str]] = {}
        self.dirty: Dict[Tuple[str, str], int] = {}      # key → 脏版本号
        self.pending_msgs: Dict[Tuple[str, str], List[str]] = {}
        self.lock = threading.RLock()
        self.flush_lock = threading.Lock()
        self.last_ok: Dict[str, float] = {}
        self.last_error: Dict[str, Optional[str]] = {}
        self._stop = threading.Event()
        self._thread: Optional[threading.Thread] = None

    @staticmethod
    def path_for(paper: str) -> str:
        return f"{paper}.json"

    def get(self, annotator: str, paper: str, papers_ref: str) -> dict:
        key = (annotator, paper)
        with self.lock:
            if key in self.docs:
                return self.docs[key]
        data, sha = self.store.read(annotator, self.path_for(paper))
        with self.lock:
            if key in self.docs:           # 并发加载, 以先到的为准
                return self.docs[key]
            if data:
                doc = json.loads(data)
            else:
                doc = {"paper": paper, "annotator": annotator, "papers_ref": papers_ref,
                       "created_ts": time.time(), "records": {}, "fields": {}, "events": []}
            self.docs[key] = doc
            self.shas[key] = sha
            return doc

    def mark_dirty(self, annotator: str, paper: str, msg: str):
        key = (annotator, paper)
        with self.lock:
            self.dirty[key] = self.dirty.get(key, 0) + 1
            self.pending_msgs.setdefault(key, []).append(msg)

    def flush(self) -> None:
        with self.flush_lock:
            with self.lock:
                work = [(k, v, json.dumps(self.docs[k], ensure_ascii=False, indent=1).encode("utf-8"),
                         self.pending_msgs.get(k, []))
                        for k, v in self.dirty.items()]
            for key, version, data, msgs in work:
                annotator, paper = key
                n = len(msgs)
                message = f"{annotator} · {paper} · {n} change{'s' if n != 1 else ''}\n\n" + "\n".join(msgs[:50])
                try:
                    try:
                        sha = self.store.write(annotator, self.path_for(paper), data, self.shas.get(key), message)
                    except ConflictError:
                        # 别处写过这份文件: 读回、按字段合并进内存版本, 再写一次(不丢任何一边的改动)
                        remote, rsha = self.store.read(annotator, self.path_for(paper))
                        with self.lock:
                            if remote:
                                merge_docs(self.docs[key], json.loads(remote))
                            self.shas[key] = rsha
                            data = json.dumps(self.docs[key], ensure_ascii=False, indent=1).encode("utf-8")
                        sha = self.store.write(annotator, self.path_for(paper), data, rsha, message + "\n(merged)")
                except Exception as e:                       # 保持脏, 下一轮重试
                    self.last_error[annotator] = f"{type(e).__name__}: {e}"[:300]
                    continue
                with self.lock:
                    self.shas[key] = sha
                    if self.dirty.get(key) == version:       # 期间没有新改动才清脏
                        self.dirty.pop(key, None)
                        self.pending_msgs.pop(key, None)
                    else:
                        self.pending_msgs[key] = self.pending_msgs.get(key, [])[n:]
                    self.last_ok[annotator] = time.time()
                    self.last_error[annotator] = None

    def status(self, annotator: str) -> dict:
        with self.lock:
            pending = sum(1 for (a, _p) in self.dirty if a == annotator)
        return {"pending": pending, "last_ok": self.last_ok.get(annotator),
                "error": self.last_error.get(annotator), "flush_seconds": self.flush_seconds}

    def _loop(self):
        while not self._stop.wait(self.flush_seconds):
            self.flush()

    def start(self):
        if self._thread is None:
            self._thread = threading.Thread(target=self._loop, name="doc-flusher", daemon=True)
            self._thread.start()

    def stop(self):
        self._stop.set()
        self.flush()


def env_flag(name: str, default: bool) -> bool:
    v = os.environ.get(name)
    if v is None:
        return default
    return v.strip().lower() in ("1", "true", "yes", "on")