feat(ccrd): open OCR originals in the HF Search Reader
Browse files- app.py +35 -2
- static/app.js +3 -4
- 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
|
| 1333 |
-
|
| 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 |
-
${
|
| 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"])
|