Download app/store.py from roz322/annotator: direct link, hf CLI and curl.
- Browser
- Download file 21 kB
-
https://huggingface.co/spaces/roz322/annotator/resolve/main/app/store.py
- Command line
-
hf download hf://spaces/roz322/annotator/app/store.py
-
curl -L -o store.py https://huggingface.co/spaces/roz322/annotator/resolve/main/app/store.py
21 kB
| """存储层: 标注数据 + 论文语料, 两种后端同一接口。 | |
| - 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 | |
| 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() | |
| 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 | |
| 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") | |