"""Qwen few-shot page-level post-correction. Runs after recognition: takes a page's raw TrOCR lines (low-confidence words wrapped `<>`), primes Qwen with a few in-context (raw -> corrected) exemplars, and asks it to fix OCR errors while preserving archaic spelling and the line count. Backends (config.CorrectionConfig.backend): local Qwen3 via transformers (notebook `run_qwen` pattern: /no_think, strip ) api hosted Qwen over an OpenAI-compatible endpoint (OpenRouter/DashScope/…) Heavy imports (torch/transformers/requests) are lazy. """ from __future__ import annotations import json import re import time from dataclasses import dataclass, field from typing import TYPE_CHECKING from ..config import CorrectionConfig from ..config import correction as default_correction if TYPE_CHECKING: # pragma: no cover from ..schemas import Line _SYSTEM = """\ You are an expert transcriber correcting OCR output of an 18th-century handwritten \ manuscript journal (Johann Reinhold Forster's journal of Captain Cook's second voyage, \ 1772–1774: nautical, botanical and zoological terminology). Your job: fix obvious OCR recognition errors — misread letters, split or merged words, \ stray characters and spacing. Each input line is prefixed with a number and a pipe, like "3| some text". Strict rules: - PRESERVE the historical spelling, capitalisation, abbreviations and punctuation. Do NOT \ modernise (keep forms like "ye", "Tuns", "&", "o'clock"). - Do NOT add, remove, translate, summarise or reorder content. - Words wrapped in << >> are low-confidence — focus your corrections there, but you may \ also fix clear errors elsewhere. - Return EVERY input line, each prefixed with its SAME number and a pipe: "3| corrected \ text". Keep the same count and order. Remove the << >> markers. No commentary.\ """ def _number(lines: list[str]) -> str: return "\n".join(f"{i + 1}| {ln}" for i, ln in enumerate(lines)) _NUM_RE = re.compile(r"^\s*(\d+)\s*\|\s?(.*)$") @dataclass class CorrectionResult: # per-line corrected text aligned 1:1 with the input lines, or None if the model # returned a different number of lines (caller falls back to page_text) lines: list[str] | None page_text: str # full corrected block as returned raw_prompt_lines: list[str] = field(default_factory=list) def _marked_line(line: "Line") -> str: """Rebuild a line's text with low-confidence words wrapped <>.""" if line.words: return " ".join( f"<<{w.text}>>" if w.needs_review else w.text for w in line.words ) return line.text def _strip_think(text: str) -> str: return re.sub(r".*?", "", text, flags=re.DOTALL).strip() class PostCorrector: def __init__(self, cfg: CorrectionConfig = default_correction) -> None: self.cfg = cfg self._examples: list[dict] | None = None self._tokenizer = None self._model = None # -- few-shot exemplars ------------------------------------------------- # def _load_examples(self) -> list[dict]: if self._examples is None: try: data = json.loads(self.cfg.examples_path.read_text(encoding="utf-8")) self._examples = data.get("examples", []) if isinstance(data, dict) else data except Exception: self._examples = [] return self._examples[: max(0, self.cfg.num_shots)] def build_messages(self, marked_lines: list[str]) -> list[dict]: """system + few-shot (raw->corrected) turns + the actual page. Exemplars and the target are numbered ("N| text") on the fly so the model echoes line numbers — that makes the reply robust to line drift.""" messages = [{"role": "system", "content": _SYSTEM}] for ex in self._load_examples(): messages.append({"role": "user", "content": _number(ex["raw"].split("\n"))}) messages.append({"role": "assistant", "content": _number(ex["corrected"].split("\n"))}) messages.append({"role": "user", "content": _number(marked_lines)}) return messages # -- backends ----------------------------------------------------------- # def _ensure_local(self): if self._model is not None: return import torch from transformers import AutoModelForCausalLM, AutoTokenizer device = self.cfg.device if device == "auto": device = "cuda" if torch.cuda.is_available() else "cpu" self._tokenizer = AutoTokenizer.from_pretrained(self.cfg.model_id) dtype = torch.float16 if device == "cuda" else torch.float32 model = AutoModelForCausalLM.from_pretrained(self.cfg.model_id, dtype=dtype) model.to(device) model.eval() self._model = model self._device = device self._torch = torch def _run_local(self, messages: list[dict]) -> str: self._ensure_local() torch, tok, model = self._torch, self._tokenizer, self._model # Qwen3: disable thinking (append /no_think, like the notebook). msgs = list(messages) msgs[-1] = {**msgs[-1], "content": msgs[-1]["content"] + "\n/no_think"} prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) inputs = tok(prompt, return_tensors="pt").to(self._device) gen_kwargs = dict(max_new_tokens=self.cfg.max_new_tokens, pad_token_id=tok.eos_token_id) if self.cfg.temperature > 0.05: gen_kwargs.update(do_sample=True, temperature=self.cfg.temperature) else: gen_kwargs.update(do_sample=False) with torch.no_grad(): out = model.generate(**inputs, **gen_kwargs) new_tokens = out[0][inputs["input_ids"].shape[1]:] return _strip_think(tok.decode(new_tokens, skip_special_tokens=True)) def _run_api(self, messages: list[dict]) -> str: import requests # No silent default: fail loudly so the corrector never posts to an # unexpected host. Set CORRECTION_API_BASE (e.g. Groq's # https://api.groq.com/openai/v1) and a key in the environment/.env. base = (self.cfg.api_base or "").rstrip("/") if not base: raise ValueError( "CORRECTION_BACKEND=api but CORRECTION_API_BASE is not set " "(e.g. https://api.groq.com/openai/v1)." ) if not self.cfg.api_key: raise ValueError( "CORRECTION_BACKEND=api but no API key found " "(set CORRECTION_API_KEY or GROQ_API_KEY)." ) # Disable Qwen3 "thinking" (soft switch) so reasoning tokens don't eat the # completion budget and truncate long pages. Also cap output generously. msgs = list(messages) msgs[-1] = {**msgs[-1], "content": msgs[-1]["content"] + "\n/no_think"} payload = {"model": self.cfg.api_model, "messages": msgs, "temperature": self.cfg.temperature, "max_tokens": self.cfg.max_new_tokens} headers = {"Authorization": f"Bearer {self.cfg.api_key}"} # Retry on 429 (free-tier rate limits) honouring Retry-After, with backoff. for attempt in range(4): r = requests.post(f"{base}/chat/completions", headers=headers, json=payload, timeout=120) if r.status_code == 429 and attempt < 3: wait = float(r.headers.get("retry-after", 2 ** attempt * 2)) time.sleep(min(wait, 30)) continue r.raise_for_status() return _strip_think(r.json()["choices"][0]["message"]["content"].strip()) r.raise_for_status() # exhausted retries return "" # -- public API --------------------------------------------------------- # def correct_page(self, lines: list["Line"]) -> CorrectionResult: marked = [_marked_line(ln) for ln in lines] if not marked: return CorrectionResult(lines=[], page_text="", raw_prompt_lines=[]) messages = self.build_messages(marked) reply = self._run_api(messages) if self.cfg.backend == "api" else self._run_local(messages) # Parse "N| text" replies -> {line_number: corrected_text}. Robust to the # model dropping/merging lines: we map by number and fill any gaps from raw. parsed: dict[int, str] = {} for row in reply.split("\n"): m = _NUM_RE.match(row) if m: parsed[int(m.group(1))] = m.group(2).strip() n = len(lines) hits = sum(1 for i in range(1, n + 1) if i in parsed) if hits < max(1, n // 2): # too few numbered lines came back — treat as failure return CorrectionResult(lines=None, page_text=reply.strip(), raw_prompt_lines=marked) # Fill gaps with the original line text (markers stripped) so output is 1:1. aligned = [parsed.get(i + 1, lines[i].text) for i in range(n)] return CorrectionResult(lines=aligned, page_text="\n".join(aligned), raw_prompt_lines=marked)