Spaces:
Sleeping
Sleeping
Pointf5ive commited on
Commit Β·
4ac6999
1
Parent(s): 5fef618
Integrate punctuation corrector into OCR routing and review learning
Browse files- smoke_signal/scripts/punct_corrector.py +233 -0
- smoke_signal_tab.py +133 -14
smoke_signal/scripts/punct_corrector.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smoke Signal β Punctuation Corrector
|
| 3 |
+
Post-OCR punctuation correction and routing signal.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import difflib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
SS_ROOT = Path(os.environ.get("SS_DATA_ROOT", os.environ.get("SS_ROOT", "/tmp/smoke_signal")))
|
| 17 |
+
CALIBRATION_DIR = SS_ROOT / "calibration"
|
| 18 |
+
CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
CORRECTION_MAP_PATH = CALIBRATION_DIR / "punct_correction_map.json"
|
| 21 |
+
FLAGS_LOG_PATH = CALIBRATION_DIR / "punct_flags_log.jsonl"
|
| 22 |
+
|
| 23 |
+
TERMINAL_PUNCT = set(".!?β¦")
|
| 24 |
+
INLINE_PUNCT = set(",;:'\"-β/()[]")
|
| 25 |
+
SMART_QUOTES = {"β", "β", "β", "β"}
|
| 26 |
+
|
| 27 |
+
KNOWN_SUBSTITUTIONS: dict[str, str] = {
|
| 28 |
+
"l": "!",
|
| 29 |
+
"I": "!",
|
| 30 |
+
"|": "!",
|
| 31 |
+
"1": "!",
|
| 32 |
+
",,": "\"",
|
| 33 |
+
"''": "\"",
|
| 34 |
+
"Β·": ",",
|
| 35 |
+
"ΚΌ": "'",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
MIN_PUNCT_PER_10_WORDS = 0.8
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _load_correction_map() -> dict[str, dict[str, int]]:
|
| 42 |
+
if CORRECTION_MAP_PATH.exists():
|
| 43 |
+
try:
|
| 44 |
+
return json.loads(CORRECTION_MAP_PATH.read_text(encoding="utf-8"))
|
| 45 |
+
except Exception:
|
| 46 |
+
return {}
|
| 47 |
+
return {}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _save_correction_map(cmap: dict[str, dict[str, int]]) -> None:
|
| 51 |
+
CORRECTION_MAP_PATH.write_text(json.dumps(cmap, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _top_substitution(cmap: dict[str, dict[str, int]], raw_char: str) -> Optional[str]:
|
| 55 |
+
candidates = cmap.get(raw_char, {})
|
| 56 |
+
if not candidates:
|
| 57 |
+
return None
|
| 58 |
+
best_char, best_count = max(candidates.items(), key=lambda x: x[1])
|
| 59 |
+
return best_char if int(best_count) >= 2 else None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _split_lines(text: str) -> list[str]:
|
| 63 |
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _word_count(text: str) -> int:
|
| 67 |
+
return len(re.findall(r"\b\w+\b", text))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _punct_count(text: str) -> int:
|
| 71 |
+
punct_set = TERMINAL_PUNCT | INLINE_PUNCT | SMART_QUOTES
|
| 72 |
+
return sum(1 for ch in text if ch in punct_set)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _ends_with_terminal(line: str) -> bool:
|
| 76 |
+
return bool(line) and line[-1] in TERMINAL_PUNCT
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def validate_punctuation(text: str) -> list[dict]:
|
| 80 |
+
flags: list[dict] = []
|
| 81 |
+
for idx, line in enumerate(_split_lines(text)):
|
| 82 |
+
words = _word_count(line)
|
| 83 |
+
if words >= 4 and not _ends_with_terminal(line):
|
| 84 |
+
flags.append(
|
| 85 |
+
{
|
| 86 |
+
"rule": "missing_terminal",
|
| 87 |
+
"line": line,
|
| 88 |
+
"line_idx": idx,
|
| 89 |
+
"detail": f"Line has {words} words but no terminal punctuation.",
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
if line and line[-1] in {"l", "|", "I"} and words >= 3:
|
| 94 |
+
flags.append(
|
| 95 |
+
{
|
| 96 |
+
"rule": "probable_exclamation",
|
| 97 |
+
"line": line,
|
| 98 |
+
"line_idx": idx,
|
| 99 |
+
"detail": f"Line ends with '{line[-1]}' which may be OCR misread of '!'.",
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if re.search(r"[,;.]{2,}", line):
|
| 104 |
+
flags.append(
|
| 105 |
+
{
|
| 106 |
+
"rule": "doubled_punct",
|
| 107 |
+
"line": line,
|
| 108 |
+
"line_idx": idx,
|
| 109 |
+
"detail": "Consecutive punctuation marks detected.",
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
has_smart = any(ch in line for ch in SMART_QUOTES)
|
| 114 |
+
has_straight = any(ch in line for ch in {'"', "'"})
|
| 115 |
+
if has_smart and has_straight:
|
| 116 |
+
flags.append(
|
| 117 |
+
{
|
| 118 |
+
"rule": "mixed_quotes",
|
| 119 |
+
"line": line,
|
| 120 |
+
"line_idx": idx,
|
| 121 |
+
"detail": "Mixed smart and straight quotes in same line.",
|
| 122 |
+
}
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return flags
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def punctuation_confidence_penalty(text: str, ocr_confidence: float) -> float:
|
| 129 |
+
words = _word_count(text)
|
| 130 |
+
if words < 5:
|
| 131 |
+
return float(ocr_confidence)
|
| 132 |
+
|
| 133 |
+
punct = _punct_count(text)
|
| 134 |
+
ratio = (punct / words) * 10.0
|
| 135 |
+
if ratio >= MIN_PUNCT_PER_10_WORDS:
|
| 136 |
+
return float(ocr_confidence)
|
| 137 |
+
|
| 138 |
+
shortfall = MIN_PUNCT_PER_10_WORDS - ratio
|
| 139 |
+
penalty = min(shortfall * 0.15, 0.4)
|
| 140 |
+
return round(max(float(ocr_confidence) - penalty, 0.05), 4)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _align_chars(raw: str, gold: str) -> list[tuple[str, str]]:
|
| 144 |
+
out: list[tuple[str, str]] = []
|
| 145 |
+
matcher = difflib.SequenceMatcher(None, raw, gold, autojunk=False)
|
| 146 |
+
punct_set = TERMINAL_PUNCT | INLINE_PUNCT | SMART_QUOTES
|
| 147 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 148 |
+
if tag == "replace" and (i2 - i1) == 1 and (j2 - j1) == 1:
|
| 149 |
+
raw_char = raw[i1]
|
| 150 |
+
gold_char = gold[j1]
|
| 151 |
+
if gold_char in punct_set:
|
| 152 |
+
out.append((raw_char, gold_char))
|
| 153 |
+
return out
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def record_punctuation_correction(raw_text: str, gold_text: str, book_id: str = "") -> int:
|
| 157 |
+
if not raw_text or not gold_text or raw_text == gold_text:
|
| 158 |
+
return 0
|
| 159 |
+
|
| 160 |
+
pairs = _align_chars(raw_text, gold_text)
|
| 161 |
+
if not pairs:
|
| 162 |
+
return 0
|
| 163 |
+
|
| 164 |
+
cmap = _load_correction_map()
|
| 165 |
+
for raw_char, gold_char in pairs:
|
| 166 |
+
if raw_char not in cmap:
|
| 167 |
+
cmap[raw_char] = {}
|
| 168 |
+
cmap[raw_char][gold_char] = int(cmap[raw_char].get(gold_char, 0)) + 1
|
| 169 |
+
_save_correction_map(cmap)
|
| 170 |
+
|
| 171 |
+
entry = {
|
| 172 |
+
"ts": datetime.now(timezone.utc).isoformat(),
|
| 173 |
+
"book_id": book_id,
|
| 174 |
+
"pairs": pairs,
|
| 175 |
+
}
|
| 176 |
+
with FLAGS_LOG_PATH.open("a", encoding="utf-8") as fh:
|
| 177 |
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
| 178 |
+
|
| 179 |
+
return len(pairs)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def apply_punctuation_corrections(raw_text: str, book_id: str = "") -> tuple[str, list[dict], float]:
|
| 183 |
+
_ = book_id
|
| 184 |
+
if not raw_text:
|
| 185 |
+
return raw_text, [], 1.0
|
| 186 |
+
|
| 187 |
+
cmap = _load_correction_map()
|
| 188 |
+
|
| 189 |
+
lines = raw_text.splitlines()
|
| 190 |
+
corrected_lines: list[str] = []
|
| 191 |
+
for line in lines:
|
| 192 |
+
stripped = line.rstrip()
|
| 193 |
+
if stripped and stripped[-1] in KNOWN_SUBSTITUTIONS:
|
| 194 |
+
candidate = KNOWN_SUBSTITUTIONS[stripped[-1]]
|
| 195 |
+
if candidate in TERMINAL_PUNCT:
|
| 196 |
+
stripped = stripped[:-1] + candidate
|
| 197 |
+
corrected_lines.append(stripped)
|
| 198 |
+
text = "\n".join(corrected_lines)
|
| 199 |
+
|
| 200 |
+
for raw_seq, gold_seq in KNOWN_SUBSTITUTIONS.items():
|
| 201 |
+
if len(raw_seq) > 1:
|
| 202 |
+
text = text.replace(raw_seq, gold_seq)
|
| 203 |
+
|
| 204 |
+
result_chars: list[str] = []
|
| 205 |
+
for ch in text:
|
| 206 |
+
learned = _top_substitution(cmap, ch)
|
| 207 |
+
result_chars.append(learned if learned else ch)
|
| 208 |
+
text = "".join(result_chars)
|
| 209 |
+
|
| 210 |
+
flags = validate_punctuation(text)
|
| 211 |
+
words = _word_count(text)
|
| 212 |
+
punct = _punct_count(text)
|
| 213 |
+
if words == 0:
|
| 214 |
+
punct_score = 1.0
|
| 215 |
+
else:
|
| 216 |
+
ratio = (punct / words) * 10.0
|
| 217 |
+
punct_score = round(min(ratio / MIN_PUNCT_PER_10_WORDS, 1.0), 4)
|
| 218 |
+
|
| 219 |
+
return text, flags, punct_score
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def correction_map_summary() -> dict:
|
| 223 |
+
cmap = _load_correction_map()
|
| 224 |
+
pairs: list[dict] = []
|
| 225 |
+
for raw_char, golds in cmap.items():
|
| 226 |
+
for gold_char, count in golds.items():
|
| 227 |
+
pairs.append({"raw": raw_char, "gold": gold_char, "count": int(count)})
|
| 228 |
+
pairs.sort(key=lambda x: x["count"], reverse=True)
|
| 229 |
+
return {
|
| 230 |
+
"total_pairs": len(pairs),
|
| 231 |
+
"top_substitutions": pairs[:20],
|
| 232 |
+
"map_path": str(CORRECTION_MAP_PATH),
|
| 233 |
+
}
|
smoke_signal_tab.py
CHANGED
|
@@ -24,6 +24,7 @@ Self-improvement loop:
|
|
| 24 |
import csv
|
| 25 |
import concurrent.futures
|
| 26 |
import hashlib
|
|
|
|
| 27 |
import json
|
| 28 |
import os
|
| 29 |
import tempfile
|
|
@@ -58,6 +59,7 @@ GOLD_FILE = GOLD_DIR / "gold_corrections.jsonl"
|
|
| 58 |
DECISIONS_CSV = REVIEW_DIR / "review_decisions.csv"
|
| 59 |
QUEUE_CSV = REVIEW_DIR / "review_queue.csv"
|
| 60 |
BANNER_DATA_URI_FILE = Path(__file__).resolve().parent / "assets" / "smoke_signal_banner_data_uri.txt"
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def _load_banner_image_css() -> str:
|
|
@@ -78,6 +80,77 @@ def _load_banner_image_css() -> str:
|
|
| 78 |
|
| 79 |
SS_BANNER_IMAGE_CSS = _load_banner_image_css()
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
# ββ Confidence calibration state (in-memory, persisted to disk) ββββββββββββββββ
|
| 82 |
CALIBRATION_FILE = SS_ROOT / "manifest" / "confidence_calibration.json"
|
| 83 |
|
|
@@ -1166,6 +1239,12 @@ def run_ocr() -> tuple:
|
|
| 1166 |
)
|
| 1167 |
)
|
| 1168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1169 |
for _, row in eligible.iterrows():
|
| 1170 |
book_id = row["book_id"]
|
| 1171 |
profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
|
|
@@ -1371,33 +1450,45 @@ def run_ocr() -> tuple:
|
|
| 1371 |
conf = 0.0
|
| 1372 |
method = "skipped-no-surya"
|
| 1373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1374 |
if method in ("tesseract-noise-filtered", "tesseract-lowconf-filtered") and not regions:
|
| 1375 |
conf_class = "auto-accept"
|
| 1376 |
-
elif
|
| 1377 |
conf_class = "auto-accept"
|
| 1378 |
-
elif
|
| 1379 |
conf_class = "review-required"
|
| 1380 |
-
|
| 1381 |
-
elif conf >= default_cal["quarantine"]:
|
| 1382 |
conf_class = "low-confidence"
|
| 1383 |
-
review_pages.append(page_num)
|
| 1384 |
else:
|
| 1385 |
conf_class = "quarantine"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1386 |
quarantine_pages.append(page_num)
|
| 1387 |
|
| 1388 |
ocr_pages.append({
|
| 1389 |
"page_number": page_num,
|
| 1390 |
"route": route,
|
| 1391 |
"regions": regions,
|
| 1392 |
-
"
|
|
|
|
| 1393 |
"confidence_class": conf_class,
|
| 1394 |
"extraction_method": method,
|
|
|
|
|
|
|
| 1395 |
"render_path": page_data.get("render_path"),
|
| 1396 |
"ocred_at": datetime.utcnow().isoformat() + "Z",
|
| 1397 |
})
|
| 1398 |
|
| 1399 |
if conf_class in ("review-required", "low-confidence", "quarantine"):
|
| 1400 |
-
raw_text = " ".join(r["text"] for r in regions)[:500]
|
| 1401 |
region_id = f"{book_id}_p{page_num:04d}"
|
| 1402 |
queue_rows.append({
|
| 1403 |
"book_id": book_id,
|
|
@@ -1406,9 +1497,13 @@ def run_ocr() -> tuple:
|
|
| 1406 |
"region_id": region_id,
|
| 1407 |
"region_class": "narration",
|
| 1408 |
"crop_path": page_data.get("render_path", ""),
|
| 1409 |
-
"raw_ocr":
|
| 1410 |
-
"
|
|
|
|
|
|
|
| 1411 |
"confidence_class": conf_class,
|
|
|
|
|
|
|
| 1412 |
"status": "quarantine" if conf_class == "quarantine" else "pending",
|
| 1413 |
"reviewer": "",
|
| 1414 |
"correction": "",
|
|
@@ -1445,7 +1540,7 @@ def run_ocr() -> tuple:
|
|
| 1445 |
qdf = pd.DataFrame(queue_rows)
|
| 1446 |
if QUEUE_CSV.exists():
|
| 1447 |
existing = pd.read_csv(QUEUE_CSV)
|
| 1448 |
-
qdf = pd.concat([existing, qdf], ignore_index=True).drop_duplicates(subset=["region_id"])
|
| 1449 |
qdf.to_csv(QUEUE_CSV, index=False)
|
| 1450 |
|
| 1451 |
save_manifest_df(df)
|
|
@@ -1464,8 +1559,13 @@ def _ocr_status_html() -> str:
|
|
| 1464 |
cal = load_calibration()
|
| 1465 |
default = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 1466 |
corrections = sum(v.get("corrections",0) for v in cal.values() if isinstance(v, dict))
|
|
|
|
| 1467 |
|
| 1468 |
-
training_badge =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1469 |
|
| 1470 |
return f"""
|
| 1471 |
<div class="ss-metrics">
|
|
@@ -1544,7 +1644,8 @@ def save_review_decision(idx: int, final_text: str, action: str, reviewer: str,
|
|
| 1544 |
idx = idx % len(pending)
|
| 1545 |
item = pending.iloc[idx]
|
| 1546 |
|
| 1547 |
-
raw_text
|
|
|
|
| 1548 |
was_correct = (final_text.strip() == raw_text.strip())
|
| 1549 |
region_class = item.get("region_class","narration")
|
| 1550 |
|
|
@@ -1559,6 +1660,7 @@ def save_review_decision(idx: int, final_text: str, action: str, reviewer: str,
|
|
| 1559 |
"status": action,
|
| 1560 |
"final_text": final_text,
|
| 1561 |
"raw_text": raw_text,
|
|
|
|
| 1562 |
"reason_code": reason,
|
| 1563 |
"reviewer": reviewer or "reviewer",
|
| 1564 |
"was_correct": was_correct,
|
|
@@ -1574,22 +1676,39 @@ def save_review_decision(idx: int, final_text: str, action: str, reviewer: str,
|
|
| 1574 |
d_df.to_csv(DECISIONS_CSV, index=False)
|
| 1575 |
|
| 1576 |
# Append to gold training set
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1577 |
with open(GOLD_FILE, "a", encoding="utf-8") as f:
|
| 1578 |
f.write(json.dumps({
|
| 1579 |
**decision,
|
| 1580 |
"region_class": region_class,
|
| 1581 |
"confidence": float(item.get("confidence", 0)),
|
| 1582 |
"conf_class": item.get("confidence_class",""),
|
|
|
|
|
|
|
| 1583 |
}) + "\n")
|
| 1584 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
cal = load_calibration()
|
| 1586 |
default = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 1587 |
corrections = sum(v.get("corrections",0) for v in cal.values() if isinstance(v,dict))
|
| 1588 |
gold_count = sum(1 for _ in open(GOLD_FILE)) if GOLD_FILE.exists() else 0
|
| 1589 |
|
|
|
|
|
|
|
| 1590 |
feedback = (f"<div class='ss-training-badge'><div class='ss-pulse'></div>"
|
| 1591 |
-
f"Gold set: {gold_count} examples Β· {corrections} corrections Β· "
|
| 1592 |
-
f"auto-accept threshold: {default['auto_accept']:.0%}</div>")
|
| 1593 |
|
| 1594 |
return feedback, *get_review_item(0)
|
| 1595 |
|
|
|
|
| 24 |
import csv
|
| 25 |
import concurrent.futures
|
| 26 |
import hashlib
|
| 27 |
+
import importlib.util
|
| 28 |
import json
|
| 29 |
import os
|
| 30 |
import tempfile
|
|
|
|
| 59 |
DECISIONS_CSV = REVIEW_DIR / "review_decisions.csv"
|
| 60 |
QUEUE_CSV = REVIEW_DIR / "review_queue.csv"
|
| 61 |
BANNER_DATA_URI_FILE = Path(__file__).resolve().parent / "assets" / "smoke_signal_banner_data_uri.txt"
|
| 62 |
+
PUNCT_CORRECTOR_PATH = Path(__file__).resolve().parent / "smoke_signal" / "scripts" / "punct_corrector.py"
|
| 63 |
|
| 64 |
|
| 65 |
def _load_banner_image_css() -> str:
|
|
|
|
| 80 |
|
| 81 |
SS_BANNER_IMAGE_CSS = _load_banner_image_css()
|
| 82 |
|
| 83 |
+
|
| 84 |
+
_PUNCT_MODULE = None
|
| 85 |
+
_PUNCT_MODULE_ERROR = None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _load_punct_module():
|
| 89 |
+
global _PUNCT_MODULE, _PUNCT_MODULE_ERROR
|
| 90 |
+
if _PUNCT_MODULE is not None:
|
| 91 |
+
return _PUNCT_MODULE
|
| 92 |
+
if _PUNCT_MODULE_ERROR is not None:
|
| 93 |
+
return None
|
| 94 |
+
if not PUNCT_CORRECTOR_PATH.exists():
|
| 95 |
+
_PUNCT_MODULE_ERROR = f"not found: {PUNCT_CORRECTOR_PATH}"
|
| 96 |
+
return None
|
| 97 |
+
try:
|
| 98 |
+
spec = importlib.util.spec_from_file_location("smoke_signal_punct_corrector", str(PUNCT_CORRECTOR_PATH))
|
| 99 |
+
if spec is None or spec.loader is None:
|
| 100 |
+
_PUNCT_MODULE_ERROR = "invalid import spec"
|
| 101 |
+
return None
|
| 102 |
+
module = importlib.util.module_from_spec(spec)
|
| 103 |
+
spec.loader.exec_module(module)
|
| 104 |
+
_PUNCT_MODULE = module
|
| 105 |
+
return _PUNCT_MODULE
|
| 106 |
+
except Exception as e:
|
| 107 |
+
_PUNCT_MODULE_ERROR = str(e)
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _apply_punctuation_corrections(text: str, book_id: str) -> tuple[str, list, float]:
|
| 112 |
+
module = _load_punct_module()
|
| 113 |
+
if module is None:
|
| 114 |
+
return text, [], 1.0
|
| 115 |
+
try:
|
| 116 |
+
corrected, flags, score = module.apply_punctuation_corrections(text or "", book_id=book_id)
|
| 117 |
+
return corrected, flags, float(score)
|
| 118 |
+
except Exception:
|
| 119 |
+
return text, [], 1.0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _punctuation_confidence_penalty(text: str, confidence: float) -> float:
|
| 123 |
+
module = _load_punct_module()
|
| 124 |
+
if module is None:
|
| 125 |
+
return float(confidence)
|
| 126 |
+
try:
|
| 127 |
+
return float(module.punctuation_confidence_penalty(text or "", float(confidence)))
|
| 128 |
+
except Exception:
|
| 129 |
+
return float(confidence)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _record_punctuation_correction(raw_text: str, gold_text: str, book_id: str) -> int:
|
| 133 |
+
module = _load_punct_module()
|
| 134 |
+
if module is None:
|
| 135 |
+
return 0
|
| 136 |
+
try:
|
| 137 |
+
return int(module.record_punctuation_correction(raw_text or "", gold_text or "", book_id=book_id))
|
| 138 |
+
except Exception:
|
| 139 |
+
return 0
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _punctuation_map_summary() -> dict:
|
| 143 |
+
module = _load_punct_module()
|
| 144 |
+
if module is None:
|
| 145 |
+
return {"total_pairs": 0, "top_substitutions": []}
|
| 146 |
+
try:
|
| 147 |
+
summary = module.correction_map_summary()
|
| 148 |
+
if not isinstance(summary, dict):
|
| 149 |
+
return {"total_pairs": 0, "top_substitutions": []}
|
| 150 |
+
return summary
|
| 151 |
+
except Exception:
|
| 152 |
+
return {"total_pairs": 0, "top_substitutions": []}
|
| 153 |
+
|
| 154 |
# ββ Confidence calibration state (in-memory, persisted to disk) ββββββββββββββββ
|
| 155 |
CALIBRATION_FILE = SS_ROOT / "manifest" / "confidence_calibration.json"
|
| 156 |
|
|
|
|
| 1239 |
)
|
| 1240 |
)
|
| 1241 |
|
| 1242 |
+
punct_mod = _load_punct_module()
|
| 1243 |
+
if punct_mod is None:
|
| 1244 |
+
log.append(log_line(f"β Punctuation corrector unavailable ({_PUNCT_MODULE_ERROR or 'unknown'})"))
|
| 1245 |
+
else:
|
| 1246 |
+
log.append(log_line("β Punctuation corrector active (rule-check + confidence penalty + learning map)"))
|
| 1247 |
+
|
| 1248 |
for _, row in eligible.iterrows():
|
| 1249 |
book_id = row["book_id"]
|
| 1250 |
profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
|
|
|
|
| 1450 |
conf = 0.0
|
| 1451 |
method = "skipped-no-surya"
|
| 1452 |
|
| 1453 |
+
raw_text = " ".join(r["text"] for r in regions)[:500]
|
| 1454 |
+
corrected_text, punct_flags, punct_score = _apply_punctuation_corrections(raw_text, book_id)
|
| 1455 |
+
conf_adjusted = _punctuation_confidence_penalty(corrected_text, conf)
|
| 1456 |
+
|
| 1457 |
if method in ("tesseract-noise-filtered", "tesseract-lowconf-filtered") and not regions:
|
| 1458 |
conf_class = "auto-accept"
|
| 1459 |
+
elif conf_adjusted >= default_cal["auto_accept"]:
|
| 1460 |
conf_class = "auto-accept"
|
| 1461 |
+
elif conf_adjusted >= default_cal["review"]:
|
| 1462 |
conf_class = "review-required"
|
| 1463 |
+
elif conf_adjusted >= default_cal["quarantine"]:
|
|
|
|
| 1464 |
conf_class = "low-confidence"
|
|
|
|
| 1465 |
else:
|
| 1466 |
conf_class = "quarantine"
|
| 1467 |
+
|
| 1468 |
+
# If punctuation rules detect likely text-quality issues, force at least review-required.
|
| 1469 |
+
if punct_flags and conf_class == "auto-accept":
|
| 1470 |
+
conf_class = "review-required"
|
| 1471 |
+
|
| 1472 |
+
if conf_class in ("review-required", "low-confidence"):
|
| 1473 |
+
review_pages.append(page_num)
|
| 1474 |
+
elif conf_class == "quarantine":
|
| 1475 |
quarantine_pages.append(page_num)
|
| 1476 |
|
| 1477 |
ocr_pages.append({
|
| 1478 |
"page_number": page_num,
|
| 1479 |
"route": route,
|
| 1480 |
"regions": regions,
|
| 1481 |
+
"page_confidence_raw": conf,
|
| 1482 |
+
"page_confidence": conf_adjusted,
|
| 1483 |
"confidence_class": conf_class,
|
| 1484 |
"extraction_method": method,
|
| 1485 |
+
"punctuation_score": punct_score,
|
| 1486 |
+
"punctuation_flags": punct_flags,
|
| 1487 |
"render_path": page_data.get("render_path"),
|
| 1488 |
"ocred_at": datetime.utcnow().isoformat() + "Z",
|
| 1489 |
})
|
| 1490 |
|
| 1491 |
if conf_class in ("review-required", "low-confidence", "quarantine"):
|
|
|
|
| 1492 |
region_id = f"{book_id}_p{page_num:04d}"
|
| 1493 |
queue_rows.append({
|
| 1494 |
"book_id": book_id,
|
|
|
|
| 1497 |
"region_id": region_id,
|
| 1498 |
"region_class": "narration",
|
| 1499 |
"crop_path": page_data.get("render_path", ""),
|
| 1500 |
+
"raw_ocr": corrected_text,
|
| 1501 |
+
"raw_ocr_original": raw_text,
|
| 1502 |
+
"confidence_raw": conf,
|
| 1503 |
+
"confidence": conf_adjusted,
|
| 1504 |
"confidence_class": conf_class,
|
| 1505 |
+
"punct_score": punct_score,
|
| 1506 |
+
"punct_flags_count": len(punct_flags),
|
| 1507 |
"status": "quarantine" if conf_class == "quarantine" else "pending",
|
| 1508 |
"reviewer": "",
|
| 1509 |
"correction": "",
|
|
|
|
| 1540 |
qdf = pd.DataFrame(queue_rows)
|
| 1541 |
if QUEUE_CSV.exists():
|
| 1542 |
existing = pd.read_csv(QUEUE_CSV)
|
| 1543 |
+
qdf = pd.concat([existing, qdf], ignore_index=True).drop_duplicates(subset=["region_id"], keep="last")
|
| 1544 |
qdf.to_csv(QUEUE_CSV, index=False)
|
| 1545 |
|
| 1546 |
save_manifest_df(df)
|
|
|
|
| 1559 |
cal = load_calibration()
|
| 1560 |
default = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 1561 |
corrections = sum(v.get("corrections",0) for v in cal.values() if isinstance(v, dict))
|
| 1562 |
+
punct_pairs = int(_punctuation_map_summary().get("total_pairs", 0))
|
| 1563 |
|
| 1564 |
+
training_badge = (
|
| 1565 |
+
f'<div class="ss-training-badge"><div class="ss-pulse"></div>'
|
| 1566 |
+
f'{corrections} corrections logged Β· punct map={punct_pairs} pairs Β· '
|
| 1567 |
+
f'thresholds auto={default["auto_accept"]:.0%} review={default["review"]:.0%}</div>'
|
| 1568 |
+
)
|
| 1569 |
|
| 1570 |
return f"""
|
| 1571 |
<div class="ss-metrics">
|
|
|
|
| 1644 |
idx = idx % len(pending)
|
| 1645 |
item = pending.iloc[idx]
|
| 1646 |
|
| 1647 |
+
raw_text = item.get("raw_ocr", "")
|
| 1648 |
+
raw_text_original = item.get("raw_ocr_original", raw_text)
|
| 1649 |
was_correct = (final_text.strip() == raw_text.strip())
|
| 1650 |
region_class = item.get("region_class","narration")
|
| 1651 |
|
|
|
|
| 1660 |
"status": action,
|
| 1661 |
"final_text": final_text,
|
| 1662 |
"raw_text": raw_text,
|
| 1663 |
+
"raw_text_original": raw_text_original,
|
| 1664 |
"reason_code": reason,
|
| 1665 |
"reviewer": reviewer or "reviewer",
|
| 1666 |
"was_correct": was_correct,
|
|
|
|
| 1676 |
d_df.to_csv(DECISIONS_CSV, index=False)
|
| 1677 |
|
| 1678 |
# Append to gold training set
|
| 1679 |
+
try:
|
| 1680 |
+
punct_score = float(item.get("punct_score", 1.0))
|
| 1681 |
+
except Exception:
|
| 1682 |
+
punct_score = 1.0
|
| 1683 |
+
try:
|
| 1684 |
+
punct_flags_count = int(float(item.get("punct_flags_count", 0)))
|
| 1685 |
+
except Exception:
|
| 1686 |
+
punct_flags_count = 0
|
| 1687 |
+
|
| 1688 |
with open(GOLD_FILE, "a", encoding="utf-8") as f:
|
| 1689 |
f.write(json.dumps({
|
| 1690 |
**decision,
|
| 1691 |
"region_class": region_class,
|
| 1692 |
"confidence": float(item.get("confidence", 0)),
|
| 1693 |
"conf_class": item.get("confidence_class",""),
|
| 1694 |
+
"punct_score": punct_score,
|
| 1695 |
+
"punct_flags_count": punct_flags_count,
|
| 1696 |
}) + "\n")
|
| 1697 |
|
| 1698 |
+
learned_pairs = 0
|
| 1699 |
+
if action == "edited":
|
| 1700 |
+
learned_pairs = _record_punctuation_correction(raw_text_original, final_text, item.get("book_id", ""))
|
| 1701 |
+
|
| 1702 |
cal = load_calibration()
|
| 1703 |
default = cal.get("_default", DEFAULT_CALIBRATION["_default"])
|
| 1704 |
corrections = sum(v.get("corrections",0) for v in cal.values() if isinstance(v,dict))
|
| 1705 |
gold_count = sum(1 for _ in open(GOLD_FILE)) if GOLD_FILE.exists() else 0
|
| 1706 |
|
| 1707 |
+
punct_pairs = int(_punctuation_map_summary().get("total_pairs", 0))
|
| 1708 |
+
learned_note = f" Β· +{learned_pairs} punct learns" if learned_pairs else ""
|
| 1709 |
feedback = (f"<div class='ss-training-badge'><div class='ss-pulse'></div>"
|
| 1710 |
+
f"Gold set: {gold_count} examples Β· {corrections} corrections Β· punct map: {punct_pairs} pairs"
|
| 1711 |
+
f"{learned_note} Β· auto-accept threshold: {default['auto_accept']:.0%}</div>")
|
| 1712 |
|
| 1713 |
return feedback, *get_review_item(0)
|
| 1714 |
|