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 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 conf >= default_cal["auto_accept"]:
1377
  conf_class = "auto-accept"
1378
- elif conf >= default_cal["review"]:
1379
  conf_class = "review-required"
1380
- review_pages.append(page_num)
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
- "page_confidence": conf,
 
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": raw_text,
1410
- "confidence": conf,
 
 
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 = f'<div class="ss-training-badge"><div class="ss-pulse"></div>{corrections} corrections logged Β· thresholds auto={default["auto_accept"]:.0%} review={default["review"]:.0%}</div>'
 
 
 
 
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 = item.get("raw_ocr","")
 
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