htr-vlm-annotator / prompts.py
dhuser's picture
Initial HTR VLM Annotator app
58cd314
Raw
History Blame Contribute Delete
17.3 kB
from __future__ import annotations
import time
import uuid
from collections import defaultdict
from dataclasses import dataclass, field, asdict
from typing import Optional
from paths import PROMPTS_DIR, read_text
OCR_SYSTEM_TPL = read_text(PROMPTS_DIR / "ocr_system.txt")
OCR_USER_ZERO_SHOT_TPL = read_text(PROMPTS_DIR / "ocr_user_zero_shot.txt")
OCR_FEW_SHOT_HEADER_TPL = read_text(PROMPTS_DIR / "ocr_user_few_shot_header.txt")
OCR_FEW_SHOT_TARGET_TPL = read_text(PROMPTS_DIR / "ocr_user_few_shot_target.txt")
EXPERT_SYSTEM_TPL = read_text(PROMPTS_DIR / "expert_system.txt")
EXPERT_USER_TPL = read_text(PROMPTS_DIR / "expert_user.txt")
JUDGE_SYSTEM_TPL = read_text(PROMPTS_DIR / "judge_system.txt")
JUDGE_USER_TPL = read_text(PROMPTS_DIR / "judge_user.txt")
def _safe_format(template: str, **values) -> str:
"""Format with missing keys treated as empty string."""
return template.format_map(defaultdict(str, values))
def render_ocr_system(
language: str, guidelines: str,
override: Optional[str] = None, mode: str = "lines",
json_template: Optional[str] = None,
) -> str:
if override:
tpl = override
else:
tpl = OCR_SYSTEM_TPL_BY_MODE.get(mode, OCR_SYSTEM_TPL)
return _safe_format(
tpl,
language=language or "(unspecified)",
guidelines=guidelines or "",
json_template=(json_template or "").strip() or '{"lines": ["line 1", "line 2"]}',
)
def render_ocr_user_target(few_shot: bool, override: Optional[str] = None) -> str:
if few_shot:
return _safe_format(OCR_FEW_SHOT_TARGET_TPL)
tpl = override if override else OCR_USER_ZERO_SHOT_TPL
return _safe_format(tpl)
def render_ocr_few_shot_header(n_examples: int) -> str:
return _safe_format(OCR_FEW_SHOT_HEADER_TPL, n_examples=n_examples)
def render_expert_system(language: str, guidelines: str, override: Optional[str] = None) -> str:
tpl = override if override else EXPERT_SYSTEM_TPL
return _safe_format(tpl, language=language or "(unspecified)", guidelines=guidelines or "")
def render_expert_user(ocr_text: str, override: Optional[str] = None) -> str:
tpl = override if override else EXPERT_USER_TPL
return _safe_format(tpl, ocr_text=ocr_text)
def render_judge_system(language: str, guidelines: str, override: Optional[str] = None) -> str:
tpl = override if override else JUDGE_SYSTEM_TPL
return _safe_format(tpl, language=language or "(unspecified)", guidelines=guidelines or "")
def render_judge_user(
*,
ocr_text: str,
expert_a_text: str,
expert_a_conf: float,
expert_a_corrections: list[str],
expert_b_text: str,
expert_b_conf: float,
expert_b_corrections: list[str],
override: Optional[str] = None,
) -> str:
tpl = override if override else JUDGE_USER_TPL
return _safe_format(
tpl,
ocr_text=ocr_text,
expert_a_text=expert_a_text,
expert_a_conf=f"{expert_a_conf:.2f}",
expert_a_corrections="; ".join(expert_a_corrections) or "(none)",
expert_b_text=expert_b_text,
expert_b_conf=f"{expert_b_conf:.2f}",
expert_b_corrections="; ".join(expert_b_corrections) or "(none)",
)
@dataclass
class ICLExample:
id: str
image_b64: str
text: str
language: str = ""
source: str = "corrected"
added_at: float = field(default_factory=lambda: time.time())
def to_public(self) -> dict:
d = asdict(self)
d["preview"] = (self.text[:120] + "…") if len(self.text) > 120 else self.text
d["n_lines"] = self.text.count("\n") + 1 if self.text else 0
return d
def to_jsonl_dict(self) -> dict:
return {
"id": self.id,
"image_b64": self.image_b64,
"text": self.text,
"language": self.language,
"source": self.source,
"added_at": self.added_at,
}
class ICLPool:
def __init__(self):
self._items: list[ICLExample] = []
def __len__(self) -> int:
return len(self._items)
@property
def items(self) -> list[ICLExample]:
return list(self._items)
def add(self, *, image_b64: str, text: str, language: str = "", source: str = "corrected") -> ICLExample:
for it in self._items:
if it.image_b64 == image_b64:
it.text = text
it.language = language or it.language
it.source = source
it.added_at = time.time()
return it
ex = ICLExample(
id=uuid.uuid4().hex[:12],
image_b64=image_b64,
text=text,
language=language,
source=source,
)
self._items.append(ex)
return ex
def remove(self, item_id: str) -> bool:
before = len(self._items)
self._items = [it for it in self._items if it.id != item_id]
return len(self._items) < before
def filter(self, language: str) -> list[ICLExample]:
if not language:
return list(self._items)
return [it for it in self._items if not it.language or it.language == language]
def sample(self, n: int, language: str = "") -> list[ICLExample]:
if n <= 0:
return []
pool = self.filter(language)
pool = sorted(pool, key=lambda it: it.added_at, reverse=True)
return pool[:n]
def to_jsonl_dicts(self) -> list[dict]:
return [it.to_jsonl_dict() for it in self._items]
def public_view(self) -> list[dict]:
return [it.to_public() for it in self._items]
# ───────────────────────────────────────────────────────────────────────────
# Prompt presets (selectable from the UI, then editable)
# ───────────────────────────────────────────────────────────────────────────
# ───────────────────────────────────────────────────────────────────────────
# OCR output modes — only TWO modes, on purpose, to keep the UI simple:
# 1. "lines" → fixed simple shape {"lines": ["...", "..."]}
# 2. "custom_json" → user pastes a JSON template; the model must fill it.
# Any specialised shape (with_expansions, with_page_meta, catalogue entry…)
# is just a JSON_TEMPLATE_PRESETS entry the user can load and edit.
# ───────────────────────────────────────────────────────────────────────────
OCR_SYSTEM_TPL_LINES = OCR_SYSTEM_TPL # the file template, asks for {"lines": [str]}
OCR_SYSTEM_TPL_CUSTOM_JSON = """You are an expert palaeographer and Handwritten Text Recognition (HTR) annotator.
Target language / script: {language}
Transcription policy (apply this strictly to every string value you produce):
{guidelines}
Task: read the attached page image and produce a SINGLE JSON object that matches EXACTLY the following template — same keys, same nesting, same array structure. Fill the placeholder values with the actual content from the page; do not add, remove, or rename any field.
Required JSON template (your reply must use this exact shape):
{json_template}
Output rules:
- Reply with a single JSON object and nothing else (no markdown, no prose, no code fences).
- Preserve UTF-8 characters exactly as written on the page.
- Use null when a field genuinely has no value on the page (e.g. no title).
- Use "[…]" inside string values for unreadable portions.
- If a field is an array of strings, fill it with one entry per physical line / item, top to bottom.
"""
OCR_SYSTEM_TPL_BY_MODE = {
"lines": OCR_SYSTEM_TPL_LINES,
"custom_json": OCR_SYSTEM_TPL_CUSTOM_JSON,
}
# ───────────────────────────────────────────────────────────────────────────
# JSON template presets — the user loads one as a starting point, then edits
# the fields they care about. The model is asked to fill this exact shape.
# ───────────────────────────────────────────────────────────────────────────
JSON_TEMPLATE_PRESETS = {
"simple_lines": (
'{\n'
' "lines": ["line 1", "line 2", "line 3"]\n'
'}'
),
"page_with_metadata": (
'{\n'
' "no_page": "12r",\n'
' "titre": "Liber primus",\n'
' "lines": ["line 1", "line 2", "line 3"]\n'
'}'
),
"with_expansions": (
'{\n'
' "lines": [\n'
' {"raw": "Sancti Ioãnis euãgelium", "expanded": "Sancti Ioannis euangelium"},\n'
' {"raw": "...", "expanded": "..."}\n'
' ]\n'
'}'
),
"catalogue_entry": (
'{\n'
' "folio": "12r",\n'
' "auteur": "Augustinus",\n'
' "titre": "De civitate dei",\n'
' "incipit": "...",\n'
' "explicit": "...",\n'
' "langue": "Latin",\n'
' "lines": ["..."]\n'
'}'
),
"structured_page_typed_lines": (
'{\n'
' "page_meta": {\n'
' "main_language": "Latin",\n'
' "script": "caroline minuscule",\n'
' "estimated_period": "XIIᵉ s.",\n'
' "layout": "single-column"\n'
' },\n'
' "lines": [\n'
' {"n": 1, "text": "...", "type": "title"},\n'
' {"n": 2, "text": "...", "type": "body"}\n'
' ]\n'
'}'
),
}
# ───────────────────────────────────────────────────────────────────────────
# Guidelines presets — these populate the {guidelines} placeholder.
# Picking one of these is the *primary* way for the user to define the
# transcription policy. Templates above only enforce STRUCTURE, not POLICY.
# ───────────────────────────────────────────────────────────────────────────
GUIDELINES_PRESETS = {
"preserve_as_is": (
"Preserve the original orthography exactly as written; do not modernise spelling.\n"
"Preserve line breaks: one transcribed line per physical line on the page.\n"
"Keep abbreviation marks, ligatures, long-s (ſ), historical letters and special signs as written.\n"
"Do not silently expand abbreviations.\n"
"Preserve capitalisation and punctuation as on the source."
),
"expand_abbreviations": (
"Expand every abbreviation silently into its resolved form (e.g. Ioãnis → Ioannis, p̃r → pater).\n"
"Preserve historical orthography otherwise (do not modernise spelling).\n"
"Preserve line breaks and capitalisation.\n"
"Use square brackets [ ] only when you supply letters that are not in the source."
),
"modernise_spelling": (
"Normalise spelling to modern conventions for {language} while keeping the meaning intact.\n"
"Expand abbreviations silently.\n"
"Modernise punctuation if necessary.\n"
"Preserve line breaks of the original page."
),
"strict_diplomatic": (
"Strict diplomatic transcription: reproduce EVERYTHING you see, including capitalisation,\n"
"punctuation marks, deleted/struck-through text (mark with ⟨…⟩), interlinear additions (mark with \\…/),\n"
"abbreviation signs, ligatures, decorations.\n"
"Use [...] for unreadable portions and (...) when you tentatively restore something."
),
}
# ───────────────────────────────────────────────────────────────────────────
# System-prompt presets for the OCR call. The default for each mode is
# picked automatically by render_ocr_system(); these presets give the user
# alternative wordings to load and edit.
# ───────────────────────────────────────────────────────────────────────────
OCR_SYSTEM_PRESETS = {
"default_plain_lines": OCR_SYSTEM_TPL_LINES,
"default_custom_json": OCR_SYSTEM_TPL_CUSTOM_JSON,
"minimal_lines": (
"Transcribe the page image in {language}. Apply this policy:\n{guidelines}\n\n"
"Reply with JSON only: {{\"lines\": [\"line 1\", \"line 2\", ...]}}"
),
"minimal_custom_json": (
"Transcribe the page image in {language}. Apply this policy:\n{guidelines}\n\n"
"Reply ONLY with a JSON object matching this exact shape:\n{json_template}"
),
}
OCR_USER_PRESETS = {
"default": OCR_USER_ZERO_SHOT_TPL,
"verbose": (
"Please transcribe the attached page image, line by line, top to bottom.\n"
"Apply the policy and the JSON shape specified in your system prompt."
),
"concise": "Transcribe line by line. Reply with the JSON specified in the system prompt.",
}
EXPERT_SYSTEM_PRESETS = {
"default__balanced": EXPERT_SYSTEM_TPL,
"strict__minimal_changes": (
"You are a senior palaeographer reviewing an HTR / OCR prediction.\n\n"
"Target language / script: {language}\n\n"
"Transcription policy (apply strictly):\n{guidelines}\n\n"
"Be CONSERVATIVE: only change a token if you are highly confident the image shows something different. "
"Pay extra attention to: u/v, i/j, long-s (ſ), abbreviation marks (macrons, tildes, ⁊), punctuation, ligatures, capitalisation.\n\n"
"Output format — reply with a single JSON object only:\n"
"{{\"corrected_text\": \"line 1\\nline 2\\n...\",\n"
" \"confidence\": 0.0,\n"
" \"corrections\": [\"description of correction 1\", \"...\"]}}"
),
"lenient__rewrite_freely": (
"You are a palaeography expert correcting an HTR / OCR prediction.\n\n"
"Target language / script: {language}\n\n"
"Transcription policy (apply strictly):\n{guidelines}\n\n"
"Be willing to depart freely from the OCR when the image clearly disagrees. "
"Prefer a fully-rewritten line over a partial patch when the prediction is badly corrupted.\n\n"
"Output format — reply with a single JSON object only:\n"
"{{\"corrected_text\": \"...\\n...\", \"confidence\": 0.0, \"corrections\": [\"...\"]}}"
),
}
EXPERT_USER_PRESETS = {
"default": EXPERT_USER_TPL,
"concise": "HTR prediction:\n{ocr_text}\n\nVerify against the image. Reply with the JSON specified in your system prompt.",
}
JUDGE_SYSTEM_PRESETS = {
"default": JUDGE_SYSTEM_TPL,
"consensus_first": (
"You are the judge in a two-expert HTR / OCR correction pipeline.\n\n"
"Target language / script: {language}\n\n"
"Transcription policy (apply strictly):\n{guidelines}\n\n"
"Workflow: if Expert A and Expert B agree on a line, take that reading. "
"Only adjudicate on lines where they differ, picking the reading most faithful to the image. "
"You may synthesise or override entirely.\n\n"
"Output format — reply with a single JSON object only:\n"
"{{\"final_text\": \"...\\n...\", \"confidence\": 0.0,\n"
" \"source\": \"expert_a\" | \"expert_b\" | \"synthesis\" | \"original\",\n"
" \"rationale\": \"one short sentence\"}}"
),
}
JUDGE_USER_PRESETS = {
"default": JUDGE_USER_TPL,
}
def list_preset_names() -> dict:
"""Public view of all preset names for the frontend."""
return {
"guidelines": list(GUIDELINES_PRESETS.keys()),
"json_template": list(JSON_TEMPLATE_PRESETS.keys()),
"ocr_system": list(OCR_SYSTEM_PRESETS.keys()),
"ocr_user": list(OCR_USER_PRESETS.keys()),
"expert_system": list(EXPERT_SYSTEM_PRESETS.keys()),
"expert_user": list(EXPERT_USER_PRESETS.keys()),
"judge_system": list(JUDGE_SYSTEM_PRESETS.keys()),
"judge_user": list(JUDGE_USER_PRESETS.keys()),
}
def get_preset(family: str, name: str) -> str:
"""Look up a single preset body. Returns empty string if not found."""
table = {
"guidelines": GUIDELINES_PRESETS,
"json_template": JSON_TEMPLATE_PRESETS,
"ocr_system": OCR_SYSTEM_PRESETS,
"ocr_user": OCR_USER_PRESETS,
"expert_system": EXPERT_SYSTEM_PRESETS,
"expert_user": EXPERT_USER_PRESETS,
"judge_system": JUDGE_SYSTEM_PRESETS,
"judge_user": JUDGE_USER_PRESETS,
}.get(family) or {}
return table.get(name, "")