vomebook commited on
Commit
b42bcff
·
verified ·
1 Parent(s): 4e62863

feat(ccrd): open OCR originals in the HF Search Reader

Browse files
Files changed (3) hide show
  1. app.py +35 -2
  2. static/app.js +3 -4
  3. tests/test_ocr_release.py +7 -0
app.py CHANGED
@@ -16,7 +16,7 @@ from functools import lru_cache
16
  from contextlib import asynccontextmanager, contextmanager
17
  from pathlib import Path
18
  from typing import Literal, Optional
19
- from urllib.parse import parse_qs, quote, unquote
20
  from fastapi import FastAPI, Query, Request
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.middleware.gzip import GZipMiddleware
@@ -27,6 +27,10 @@ from pydantic import BaseModel, Field
27
  import ocr_release
28
  BASE_DIR = Path(__file__).resolve().parent
29
  DATA_PATH = BASE_DIR / "data/search_data.json.gz"
 
 
 
 
30
  FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
31
  FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
32
  FULLTEXT_DIR = BASE_DIR / "data/fulltext"
@@ -103,6 +107,7 @@ FULLTEXT_DISK_SOURCES = frozenset(
103
  FULLTEXT_REFRESH_INTERVAL_SECONDS = max(1, int(os.environ.get("CCRD_FULLTEXT_REFRESH_INTERVAL_SECONDS", "5")))
104
  fulltext_refresh_stop = threading.Event()
105
  active_ocr_release = None
 
106
 
107
  def clear_metadata_match_cache() -> None:
108
  global metadata_match_cache_generation
@@ -1053,9 +1058,28 @@ def build_indexes() -> None:
1053
  source_size_order[public_source_slug(records[idx])].append(idx)
1054
  clear_metadata_match_cache()
1055
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  def load_data() -> None:
1057
  global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data, active_ocr_release
1058
- global source_physical_map, physical_source_counts, ocr_public_browser
1059
  start = time.time()
1060
  payload = load_json_gz(DATA_PATH)
1061
  extra = None
@@ -1102,6 +1126,12 @@ def load_data() -> None:
1102
  folder_browser_data.update(layout["browsers"])
1103
  ocr_public_browser = layout["browsers"]
1104
  active_ocr_release = extra
 
 
 
 
 
 
1105
  build_indexes()
1106
  print(f"loaded {len(records)} txt records in {time.time() - start:.2f}s")
1107
 
@@ -1967,6 +1997,9 @@ def api_preview(doc_id: str):
1967
  if rec.get("source") == "OCR":
1968
  payload.update(text_quality=rec.get("text_quality", "unreviewed"),
1969
  text_url="/txt/OCR/" + quote(rec["display_rel_path"], safe="/"))
 
 
 
1970
  return JSONResponse(payload)
1971
  @app.get("/api/snippet/{doc_id}")
1972
 
 
16
  from contextlib import asynccontextmanager, contextmanager
17
  from pathlib import Path
18
  from typing import Literal, Optional
19
+ from urllib.parse import parse_qs, quote, unquote, urlencode
20
  from fastapi import FastAPI, Query, Request
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.middleware.gzip import GZipMiddleware
 
27
  import ocr_release
28
  BASE_DIR = Path(__file__).resolve().parent
29
  DATA_PATH = BASE_DIR / "data/search_data.json.gz"
30
+ # Existing Hugging Face Search Reader; OCR previews deep-link to the original
31
+ # document there instead of exposing the generated plain-text file.
32
+ HF_SEARCH_READER = os.environ.get("CCRD_HF_SEARCH_READER",
33
+ "https://voiceofml-search.hf.space/static/reader.html")
34
  FOLDER_TREE_PATH = BASE_DIR / "data/folder_tree.json.gz"
35
  FOLDER_BROWSER_PATH = BASE_DIR / "data/folder_browser.json.gz"
36
  FULLTEXT_DIR = BASE_DIR / "data/fulltext"
 
107
  FULLTEXT_REFRESH_INTERVAL_SECONDS = max(1, int(os.environ.get("CCRD_FULLTEXT_REFRESH_INTERVAL_SECONDS", "5")))
108
  fulltext_refresh_stop = threading.Event()
109
  active_ocr_release = None
110
+ ocr_reader_urls: dict[str, str] = {}
111
 
112
  def clear_metadata_match_cache() -> None:
113
  global metadata_match_cache_generation
 
1058
  source_size_order[public_source_slug(records[idx])].append(idx)
1059
  clear_metadata_match_cache()
1060
 
1061
+ def ocr_reader_url(export: dict) -> str:
1062
+ """Deep link to the existing HF Search Reader for an OCR origin document.
1063
+
1064
+ The Reader accepts only public ``VoiceOfML/<repo>`` dataset ``resolve/main``
1065
+ sources; the commit-pinned revision remains provenance, not a Reader URL.
1066
+ """
1067
+ repo = export.get("origin_repo") or ""
1068
+ path = export.get("origin_rel_path") or ""
1069
+ if not repo.startswith("VoiceOfML/") or not path:
1070
+ return ""
1071
+ source = f"https://huggingface.co/datasets/{repo}/resolve/main/" + quote(path, safe="/")
1072
+ filename = path.rsplit("/", 1)[-1]
1073
+ extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
1074
+ query = {"url": source}
1075
+ if extension:
1076
+ query["ext"] = extension
1077
+ return f"{HF_SEARCH_READER}?{urlencode(query)}"
1078
+
1079
+
1080
  def load_data() -> None:
1081
  global records, record_map, record_map_index, sources, source_counts, folder_tree_data, folder_browser_data, active_ocr_release
1082
+ global source_physical_map, physical_source_counts, ocr_public_browser, ocr_reader_urls
1083
  start = time.time()
1084
  payload = load_json_gz(DATA_PATH)
1085
  extra = None
 
1126
  folder_browser_data.update(layout["browsers"])
1127
  ocr_public_browser = layout["browsers"]
1128
  active_ocr_release = extra
1129
+ ocr_reader_urls = {}
1130
+ if extra:
1131
+ for export in extra.get("exports", []):
1132
+ url = ocr_reader_url(export)
1133
+ if url:
1134
+ ocr_reader_urls[export["doc_id"]] = url
1135
  build_indexes()
1136
  print(f"loaded {len(records)} txt records in {time.time() - start:.2f}s")
1137
 
 
1997
  if rec.get("source") == "OCR":
1998
  payload.update(text_quality=rec.get("text_quality", "unreviewed"),
1999
  text_url="/txt/OCR/" + quote(rec["display_rel_path"], safe="/"))
2000
+ reader_url = ocr_reader_urls.get(doc_id)
2001
+ if reader_url:
2002
+ payload["reader_url"] = reader_url
2003
  return JSONResponse(payload)
2004
  @app.get("/api/snippet/{doc_id}")
2005
 
static/app.js CHANGED
@@ -1329,16 +1329,15 @@ async function openPreview(docId, keyword, options = {}) {
1329
  } else if (keyword && rawText.length >= 500000) {
1330
  showToast("文本较大,已跳过预览高亮");
1331
  }
1332
- const textQuality = data.text_quality === "partially-reviewed" ? "部分校订" : data.text_quality === "unreviewed" ? "未校订 OCR" : "";
1333
- const plainTextLink = typeof data.text_url === "string" && data.text_url.startsWith("/txt/OCR/")
1334
- ? `<a href="${escapeHTML(data.text_url)}" target="_blank" rel="noopener">打开纯 TXT</a>` : "";
1335
  window.CCRDSpeech?.previewClosed();
1336
  DOM.previewPanel.innerHTML = `
1337
  <div class="preview-header">
1338
  <div class="preview-heading">
1339
  <div class="preview-title">${escapeHTML(data.title)}</div>
1340
  <div class="preview-path">${escapeHTML(data.source)} / ${escapeHTML(data.path)}</div>
1341
- ${textQuality || plainTextLink ? `<div class="preview-path">${escapeHTML(textQuality)}${plainTextLink ? " · " + plainTextLink : ""}</div>` : ""}
1342
  </div>
1343
  <div class="preview-tools">
1344
  ${previewSpeechButtonHTML()}
 
1329
  } else if (keyword && rawText.length >= 500000) {
1330
  showToast("文本较大,已跳过预览高亮");
1331
  }
1332
+ const readerLink = typeof data.reader_url === "string" && data.reader_url.startsWith("https://")
1333
+ ? `<a href="${escapeHTML(data.reader_url)}" target="_blank" rel="noopener noreferrer">打开原文件</a>` : "";
 
1334
  window.CCRDSpeech?.previewClosed();
1335
  DOM.previewPanel.innerHTML = `
1336
  <div class="preview-header">
1337
  <div class="preview-heading">
1338
  <div class="preview-title">${escapeHTML(data.title)}</div>
1339
  <div class="preview-path">${escapeHTML(data.source)} / ${escapeHTML(data.path)}</div>
1340
+ ${readerLink ? `<div class="preview-path">${readerLink}</div>` : ""}
1341
  </div>
1342
  <div class="preview-tools">
1343
  ${previewSpeechButtonHTML()}
tests/test_ocr_release.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
  import tempfile
8
  import unittest
9
  from unittest.mock import patch
 
10
 
11
  import app
12
  import build_fulltext_db as builder
@@ -278,6 +279,12 @@ class OcrReleaseTests(unittest.TestCase):
278
  self.assertEqual(client.get("/txt/OCR/missing.txt").status_code, 404)
279
  self.assertEqual(client.get("/api/preview/OCR:1").json()["text"], self.content)
280
  self.assertEqual(client.get("/api/preview/OCR:1").json()["text_quality"], "partially-reviewed")
 
 
 
 
 
 
281
  self.assertEqual(client.get("/api/preview/OCR:1").json()["path"], "Test/原目录/book.txt")
282
  self.assertEqual(client.get("/api/preview/OCR:1").json()["source"], "Test")
283
  self.assertEqual([item["slug"] for item in client.get("/api/sources").json()], ["CCRD", "Test"])
 
7
  import tempfile
8
  import unittest
9
  from unittest.mock import patch
10
+ from urllib.parse import quote, urlencode
11
 
12
  import app
13
  import build_fulltext_db as builder
 
279
  self.assertEqual(client.get("/txt/OCR/missing.txt").status_code, 404)
280
  self.assertEqual(client.get("/api/preview/OCR:1").json()["text"], self.content)
281
  self.assertEqual(client.get("/api/preview/OCR:1").json()["text_quality"], "partially-reviewed")
282
+ preview = client.get("/api/preview/OCR:1").json()
283
+ expected_source = ("https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/"
284
+ + quote("原目录/book.pdf", safe="/"))
285
+ self.assertEqual(preview["reader_url"],
286
+ "https://voiceofml-search.hf.space/static/reader.html?"
287
+ + urlencode({"url": expected_source, "ext": "pdf"}))
288
  self.assertEqual(client.get("/api/preview/OCR:1").json()["path"], "Test/原目录/book.txt")
289
  self.assertEqual(client.get("/api/preview/OCR:1").json()["source"], "Test")
290
  self.assertEqual([item["slug"] for item in client.get("/api/sources").json()], ["CCRD", "Test"])