feat(magazine): fast LLM calls for spelling agent, run-together word repair, magazine comparison tool

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 20:09:42 +02:00
parent 29b1f63e60
commit d06f990379
7 changed files with 497 additions and 28 deletions

View File

@ -6,6 +6,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [Unreleased] ## [Unreleased]
### Added
- `scripts/compare_magazines.py`: read-only comparison of two magazine folders (words, boxes, reading order, articles, quality numbers) for re-import checks.
### Changed
- Spelling agent and acronym resolver call the language model with thinking off (about 4 s instead of about 27 s per call) and retry without the option if the server rejects it.
- The spelling agent treats run-together words (`dasich`) as errors and may split them at a space when both halves are real words and the letters are unchanged.
### Added ### Added
- **Paragraphs are fused across columns and pages for narration.** A paragraph that continues into the next column or onto a later page is now one block in the reader and the exported MP3, so a sentence is no longer cut in two at the seam. Words split at the seam are closed up (`jähr-` + `lich`); real compound hyphens are kept; headings, interview turns and finished paragraphs are never glued together. Highlighting still follows the region each sentence began in. - **Paragraphs are fused across columns and pages for narration.** A paragraph that continues into the next column or onto a later page is now one block in the reader and the exported MP3, so a sentence is no longer cut in two at the seam. Words split at the seam are closed up (`jähr-` + `lich`); real compound hyphens are kept; headings, interview turns and finished paragraphs are never glued together. Highlighting still follows the region each sentence began in.

View File

@ -303,6 +303,14 @@ def accept_agent_fix(issue: dict, replacement: str) -> bool:
""" """
replacement = (replacement or "").strip() replacement = (replacement or "").strip()
token = issue["token"] token = issue["token"]
# Two words run together (dasich -> "das ich"): the same letters, split at a space, both halves
# real words. Nothing is invented, so this is provable without a suggestion list.
checker_ = _checker(issue.get("language") or "de")
halves = replacement.split(" ")
if (checker_ is not None and len(halves) == 2 and all(re.fullmatch(r"[A-Za-zÄÖÜäöüß]+", h) for h in halves)
and "".join(halves).casefold() == token.casefold()
and all(len(h) >= 2 and _known_form(h, checker_, issue.get("language") or "de") for h in halves)):
return True
if (not replacement or replacement == token if (not replacement or replacement == token
or not re.fullmatch(r"[A-Za-zÄÖÜäöüß]+(?:- (?:und|oder))?", replacement)): or not re.fullmatch(r"[A-Za-zÄÖÜäöüß]+(?:- (?:und|oder))?", replacement)):
return False return False

View File

@ -2343,7 +2343,10 @@ _SPELLING_SYSTEM = (
"{{\"token\": \"Nvidia\", \"action\": \"keep\"}}]}}. " "{{\"token\": \"Nvidia\", \"action\": \"keep\"}}]}}. "
"Use replace only when the printed word is clearly an OCR or typing error of exactly one word, judged from its " "Use replace only when the printed word is clearly an OCR or typing error of exactly one word, judged from its "
"sentence; the replacement is that single word. Use keep for proper names, brands, technical terms, foreign " "sentence; the replacement is that single word. Use keep for proper names, brands, technical terms, foreign "
"words and valid compounds, and whenever you are unsure. Never rephrase, add or remove any other words." "words and valid compounds, and whenever you are unsure. "
"Two words run together (dasich = das ich) or a word with a missing or wrong letter is an error, never a "
"term to keep: for run-together words replace with the two words separated by one space. "
"Never rephrase, add or remove any other words."
) )
@ -2414,21 +2417,12 @@ def _spelling_findings(d: Path, layout: dict[str, Any], article_id: str | None =
def _agent_check_block(settings: dict[str, Any], language: str, text: str, def _agent_check_block(settings: dict[str, Any], language: str, text: str,
issues: list[dict[str, Any]]) -> list[dict[str, Any]]: issues: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Ask the LLM about one block's findings; returns its decisions (unvalidated).""" """Ask the LLM about one block's findings; returns its decisions (unvalidated)."""
from routes.conversation import _post_llm_chat_completion resp = _llm_chat_fast(settings, [
model = str(settings.get("llm_model") or "").strip() {"role": "system", "content": _SPELLING_SYSTEM.format(language="German" if language == "de" else "English")},
payload = { {"role": "user", "content": json.dumps(
"model": model, "temperature": 0.0, "stream": False, [{"token": i["token"], "suggestions": i["suggestions"], "context": i["context"]} for i in issues],
"messages": [ ensure_ascii=False)},
{"role": "system", "content": _SPELLING_SYSTEM.format( ])
language="German" if language == "de" else "English")},
{"role": "user", "content": json.dumps(
[{"token": i["token"], "suggestions": i["suggestions"], "context": i["context"]}
for i in issues], ensure_ascii=False)},
],
}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
resp = _post_llm_chat_completion(settings.get("llm_url") or "http://localhost:11434/v1",
payload, headers, 120.0)
resp.raise_for_status() resp.raise_for_status()
decisions = _llm_json(resp.json()["choices"][0]["message"]["content"]).get("decisions") decisions = _llm_json(resp.json()["choices"][0]["message"]["content"]).get("decisions")
return decisions if isinstance(decisions, list) else [] return decisions if isinstance(decisions, list) else []
@ -2594,6 +2588,21 @@ _ACRONYM_SYSTEM = (
) )
def _llm_chat_fast(settings: dict[str, Any], messages: list[dict[str, str]], timeout: float = 120.0, max_tokens: int = 800):
"""One chat completion with the model's thinking switched off (a reasoning model otherwise spends
~30 s and hundreds of tokens per call on these small structured questions). A backend that rejects
the Qwen-style switch is asked again without it."""
from routes.conversation import _post_llm_chat_completion
payload = {"model": str(settings.get("llm_model") or "").strip(), "temperature": 0.0, "stream": False,
"max_tokens": max_tokens, "messages": messages, "chat_template_kwargs": {"enable_thinking": False}}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
url = settings.get("llm_url") or "http://localhost:11434/v1"
resp = _post_llm_chat_completion(url, payload, headers, timeout)
if resp.status_code in (400, 422):
resp = _post_llm_chat_completion(url, {k: v for k, v in payload.items() if k != "chat_template_kwargs"}, headers, timeout)
return resp
def _llm_json(raw: str) -> Any: def _llm_json(raw: str) -> Any:
"""The JSON object in a model answer, tolerating code fences, think-tags and chatter around it.""" """The JSON object in a model answer, tolerating code fences, think-tags and chatter around it."""
raw = re.sub(r"<think>.*?</think>", "", raw or "", flags=re.S).strip() raw = re.sub(r"<think>.*?</think>", "", raw or "", flags=re.S).strip()
@ -2661,19 +2670,13 @@ async def magazine_acronyms_resolve(doc_id: str, article_id: str):
if not model: if not model:
raise HTTPException(400, "No LLM model configured") raise HTTPException(400, "No LLM model configured")
language = "German" if state["language"] == "de" else "English" language = "German" if state["language"] == "de" else "English"
payload = { messages = [
"model": model, "temperature": 0.1, "stream": False, {"role": "system", "content": _ACRONYM_SYSTEM.format(language=language)},
"messages": [ {"role": "user", "content": json.dumps(
{"role": "system", "content": _ACRONYM_SYSTEM.format(language=language)}, [{"acronym": e["acronym"], "context": e["context"]} for e in unknown], ensure_ascii=False)},
{"role": "user", "content": json.dumps( ]
[{"acronym": e["acronym"], "context": e["context"]} for e in unknown], ensure_ascii=False)},
],
}
headers = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
try: try:
resp = await asyncio.to_thread( resp = await asyncio.to_thread(_llm_chat_fast, settings, messages, 150.0)
_post_llm_chat_completion, settings.get("llm_url") or "http://localhost:11434/v1",
payload, headers, 150.0)
resp.raise_for_status() resp.raise_for_status()
decided = parse_acronym_decisions( decided = parse_acronym_decisions(
_llm_json(resp.json()["choices"][0]["message"]["content"]).get("acronyms"), unknown) _llm_json(resp.json()["choices"][0]["message"]["content"]).get("acronyms"), unknown)

View File

@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Compare two processed magazine folders (a baseline and a re-import of the same PDF).
Read-only. Nothing is guessed by eye: every number comes from the two folders'
``layout.json`` / ``articles.json`` (and, when present, ``quality_report.json``).
python scripts/compare_magazines.py BASELINE_DIR NEW_DIR [--out report.json] [--md report.md]
What is compared, per page and overall:
* text - the narratable words of each page, aligned word by word (difflib), so a
re-ordered or re-segmented but identical page still scores 1.0
* boxes - regions matched by overlap (IoU >= 0.5): recall/precision, type agreement
* order - of the matched boxes, how many pairs are read in the same order
* articles - matched by shared text (word 3-grams); title, author, page span, status
* quality - pages needing a decision, unboxed areas, clipped edges, overlaps
The JSON report lists every difference so a person (or a review screen) can decide, per
difference, which side is better.
"""
from __future__ import annotations
import argparse
import difflib
import json
import re
import sys
from pathlib import Path
from typing import Any
TEXT_TYPES = {"title", "heading", "deck", "byline", "body", "quote", "caption", "sidebar", "pullquote"}
NARRATED_TYPES = {"title", "heading", "deck", "byline", "body", "quote"}
_WORD = re.compile(r"[\wÄÖÜäöüß'’-]+", re.U)
_IOU_MATCH = 0.5
_SIMILAR_PAGE = 0.98
def load_magazine(folder: Path) -> dict[str, Any]:
folder = Path(folder)
layout = json.loads((folder / "layout.json").read_text("utf-8"))
articles = json.loads((folder / "articles.json").read_text("utf-8")).get("articles", [])
quality: dict[str, Any] = {}
if (folder / "quality_report.json").is_file():
quality = json.loads((folder / "quality_report.json").read_text("utf-8"))
return {"folder": str(folder), "layout": layout, "articles": articles, "quality": quality}
def words(text: str) -> list[str]:
return [w.casefold().strip("-'’") for w in _WORD.findall(text or "") if w.strip("-'’")]
def page_words(layout: dict[str, Any], page: int) -> list[str]:
blocks = sorted((b for b in layout.get("blocks", []) if b["page"] == page and b["type"] in NARRATED_TYPES),
key=lambda b: b.get("reading_order", 0))
out: list[str] = []
for block in blocks:
out.extend(words(block.get("text", "")))
return out
def align(a: list[str], b: list[str]) -> dict[str, Any]:
"""Word-level alignment. Order-insensitive where it should be: each side is compared as read."""
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
only_a: list[str] = []
only_b: list[str] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("delete", "replace"):
only_a.extend(a[i1:i2])
if tag in ("insert", "replace"):
only_b.extend(b[j1:j2])
total = max(1, len(a) + len(b))
same = total - len(only_a) - len(only_b)
return {"similarity": 1.0 if not (a or b) else round(same / total, 4),
"words_a": len(a), "words_b": len(b), "only_in_a": only_a, "only_in_b": only_b}
def iou(a: list[float], b: list[float]) -> float:
ax, ay, aw, ah = a
bx, by, bw, bh = b
w = max(0.0, min(ax + aw, bx + bw) - max(ax, bx))
h = max(0.0, min(ay + ah, by + bh) - max(ay, by))
inter = w * h
union = aw * ah + bw * bh - inter
return inter / union if union > 0 else 0.0
def match_boxes(blocks_a: list[dict[str, Any]], blocks_b: list[dict[str, Any]]) -> list[tuple[dict[str, Any], dict[str, Any], float]]:
pairs = sorted(((iou(x["bbox"], y["bbox"]), i, j) for i, x in enumerate(blocks_a) for j, y in enumerate(blocks_b)),
reverse=True)
used_a: set[int] = set()
used_b: set[int] = set()
matched = []
for score, i, j in pairs:
if score < _IOU_MATCH:
break
if i in used_a or j in used_b:
continue
used_a.add(i)
used_b.add(j)
matched.append((blocks_a[i], blocks_b[j], score))
return matched
def order_agreement(matched: list[tuple[dict[str, Any], dict[str, Any], float]]) -> float | None:
"""Share of matched box pairs that are read in the same relative order in both."""
if len(matched) < 2:
return None
concordant = total = 0
for i in range(len(matched)):
for j in range(i + 1, len(matched)):
da = matched[i][0].get("reading_order", 0) - matched[j][0].get("reading_order", 0)
db = matched[i][1].get("reading_order", 0) - matched[j][1].get("reading_order", 0)
if da == 0 or db == 0:
continue
total += 1
concordant += (da > 0) == (db > 0)
return round(concordant / total, 4) if total else None
def compare_pages(a: dict[str, Any], b: dict[str, Any]) -> list[dict[str, Any]]:
pages_a = {p["page"] for p in a["layout"].get("pages", [])}
pages_b = {p["page"] for p in b["layout"].get("pages", [])}
rows = []
for page in sorted(pages_a | pages_b):
blocks_a = [x for x in a["layout"].get("blocks", []) if x["page"] == page and x["type"] in TEXT_TYPES]
blocks_b = [x for x in b["layout"].get("blocks", []) if x["page"] == page and x["type"] in TEXT_TYPES]
text = align(page_words(a["layout"], page), page_words(b["layout"], page))
matched = match_boxes(blocks_a, blocks_b)
images_a = [i for i in a["layout"].get("images", []) if i["page"] == page]
images_b = [i for i in b["layout"].get("images", []) if i["page"] == page]
figures = match_boxes([{"bbox": i["bbox"]} for i in images_a], [{"bbox": i["bbox"]} for i in images_b])
row = {
"page": page,
"text_similarity": text["similarity"], "words_a": text["words_a"], "words_b": text["words_b"],
"only_in_a": " ".join(text["only_in_a"][:40]), "only_in_b": " ".join(text["only_in_b"][:40]),
"boxes_a": len(blocks_a), "boxes_b": len(blocks_b), "boxes_matched": len(matched),
"box_recall": round(len(matched) / len(blocks_a), 4) if blocks_a else None,
"box_precision": round(len(matched) / len(blocks_b), 4) if blocks_b else None,
"type_agreement": round(sum(x["type"] == y["type"] for x, y, _ in matched) / len(matched), 4) if matched else None,
"type_changes": sorted({f"{x['type']}->{y['type']}" for x, y, _ in matched if x["type"] != y["type"]}),
"order_agreement": order_agreement(matched),
"hand_ordered_in_a": bool(next((p for p in a["layout"].get("pages", []) if p["page"] == page), {}).get("order_locked")),
"figures_a": len(images_a), "figures_b": len(images_b), "figures_matched": len(figures),
"page_only_in": "a" if page not in pages_b else ("b" if page not in pages_a else ""),
}
flags = []
if row["text_similarity"] < _SIMILAR_PAGE:
flags.append("text")
if row["box_recall"] is not None and row["box_recall"] < 0.9:
flags.append("boxes_missing_in_new")
if row["box_precision"] is not None and row["box_precision"] < 0.9:
flags.append("extra_boxes_in_new")
if row["type_agreement"] is not None and row["type_agreement"] < 0.95:
flags.append("types")
if row["order_agreement"] is not None and row["order_agreement"] < 0.95:
flags.append("order")
if row["figures_a"] != row["figures_b"]:
flags.append("figures")
row["flags"] = flags
rows.append(row)
return rows
def _article_text(magazine: dict[str, Any], article: dict[str, Any]) -> list[str]:
ids = set(article.get("blocks") or [])
blocks = sorted((b for b in magazine["layout"].get("blocks", []) if b["id"] in ids and b["type"] in NARRATED_TYPES),
key=lambda b: (b["page"], b.get("reading_order", 0)))
out: list[str] = []
for block in blocks:
out.extend(words(block.get("text", "")))
return out
def _shingles(ws: list[str], n: int = 3) -> set[tuple[str, ...]]:
return {tuple(ws[i:i + n]) for i in range(len(ws) - n + 1)} if len(ws) >= n else {tuple(ws)} if ws else set()
def compare_articles(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
texts_a = {x["id"]: _article_text(a, x) for x in a["articles"]}
texts_b = {x["id"]: _article_text(b, x) for x in b["articles"]}
sh_a = {k: _shingles(v) for k, v in texts_a.items()}
sh_b = {k: _shingles(v) for k, v in texts_b.items()}
scores = []
for ia, sa in sh_a.items():
for ib, sb in sh_b.items():
if not sa or not sb:
continue
inter = len(sa & sb)
if inter:
scores.append((inter / len(sa | sb), ia, ib))
scores.sort(reverse=True)
used_a: set[str] = set()
used_b: set[str] = set()
by_a = {x["id"]: x for x in a["articles"]}
by_b = {x["id"]: x for x in b["articles"]}
matched = []
for score, ia, ib in scores:
if score < 0.3 or ia in used_a or ib in used_b:
continue
used_a.add(ia)
used_b.add(ib)
xa, xb = by_a[ia], by_b[ib]
diff = align(texts_a[ia], texts_b[ib])
matched.append({
"a": ia, "b": ib, "overlap": round(score, 4), "text_similarity": diff["similarity"],
"title_a": xa.get("title", ""), "title_b": xb.get("title", ""),
"author_a": xa.get("author", ""), "author_b": xb.get("author", ""),
"pages_a": xa.get("pages", []), "pages_b": xb.get("pages", []),
"status_a": xa.get("status"), "status_b": xb.get("status"),
"same_pages": xa.get("pages", []) == xb.get("pages", []),
})
def brief(x: dict[str, Any], texts: dict[str, list[str]]) -> dict[str, Any]:
return {"id": x["id"], "title": x.get("title", ""), "pages": x.get("pages", []), "words": len(texts[x["id"]])}
return {
"matched": matched,
"only_in_a": [brief(x, texts_a) for x in a["articles"] if x["id"] not in used_a and texts_a[x["id"]]],
"only_in_b": [brief(x, texts_b) for x in b["articles"] if x["id"] not in used_b and texts_b[x["id"]]],
}
def quality_numbers(m: dict[str, Any]) -> dict[str, Any]:
rows = m["quality"].get("pages", [])
return {
"pages_needing_a_decision": sum(1 for r in rows if r.get("needs_review")),
"unboxed_areas": sum(len(r.get("uncovered", [])) for r in rows),
"clipped_edges": sum(len(r.get("clipped", [])) for r in rows),
"overlapping_pairs": sum(len(r.get("overlaps", [])) for r in rows),
"words_to_check": sum(len(r.get("spelling", [])) for r in rows),
}
def mean(values: list[float]) -> float | None:
values = [v for v in values if v is not None]
return round(sum(values) / len(values), 4) if values else None
def compare(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
pages = compare_pages(a, b)
articles = compare_articles(a, b)
words_a = sum(p["words_a"] for p in pages)
words_b = sum(p["words_b"] for p in pages)
summary = {
"baseline": a["folder"], "new": b["folder"],
"pages": len(pages),
"words": {"baseline": words_a, "new": words_b},
"text_similarity_mean": mean([p["text_similarity"] for p in pages]),
"pages_with_text_differences": sum("text" in p["flags"] for p in pages),
"box_recall_mean": mean([p["box_recall"] for p in pages]),
"box_precision_mean": mean([p["box_precision"] for p in pages]),
"type_agreement_mean": mean([p["type_agreement"] for p in pages]),
"order_agreement_mean": mean([p["order_agreement"] for p in pages]),
"pages_flagged": sum(bool(p["flags"]) for p in pages),
"figures": {"baseline": sum(p["figures_a"] for p in pages), "new": sum(p["figures_b"] for p in pages)},
"articles": {"baseline": len(a["articles"]), "new": len(b["articles"]),
"matched": len(articles["matched"]), "only_in_baseline": len(articles["only_in_a"]),
"only_in_new": len(articles["only_in_b"]),
"same_page_span": sum(x["same_pages"] for x in articles["matched"]),
"same_title": sum(x["title_a"].strip().casefold() == x["title_b"].strip().casefold() for x in articles["matched"])},
"quality": {"baseline": quality_numbers(a), "new": quality_numbers(b)},
}
return {"summary": summary, "pages": pages, "articles": articles}
def markdown(report: dict[str, Any], worst: int = 15) -> str:
s = report["summary"]
lines = ["# Magazine comparison", "", f"* baseline: `{s['baseline']}`", f"* new: `{s['new']}`", "",
"| measure | value |", "|---|---|",
f"| pages | {s['pages']} |",
f"| narratable words (baseline / new) | {s['words']['baseline']} / {s['words']['new']} |",
f"| text similarity (mean per page) | {s['text_similarity_mean']} |",
f"| pages with text differences | {s['pages_with_text_differences']} |",
f"| box recall / precision | {s['box_recall_mean']} / {s['box_precision_mean']} |",
f"| type agreement | {s['type_agreement_mean']} |",
f"| reading-order agreement | {s['order_agreement_mean']} |",
f"| figures (baseline / new) | {s['figures']['baseline']} / {s['figures']['new']} |",
f"| articles (baseline / new / matched) | {s['articles']['baseline']} / {s['articles']['new']} / {s['articles']['matched']} |",
f"| matched articles with the same page span | {s['articles']['same_page_span']} |",
f"| pages flagged | {s['pages_flagged']} |", "", "## Quality report", "",
"| | baseline | new |", "|---|---|---|"]
for key in s["quality"]["baseline"]:
lines.append(f"| {key.replace('_', ' ')} | {s['quality']['baseline'][key]} | {s['quality']['new'][key]} |")
flagged = sorted((p for p in report["pages"] if p["flags"]), key=lambda p: (p["text_similarity"], p["page"]))[:worst]
lines += ["", f"## The {len(flagged)} pages that differ most", "", "| page | flags | text | boxes b/n | order |", "|---|---|---|---|---|"]
for p in flagged:
lines.append(f"| {p['page']} | {', '.join(p['flags'])} | {p['text_similarity']} | {p['boxes_a']}/{p['boxes_b']} | {p['order_agreement']} |")
for title, key in (("Only in the baseline", "only_in_a"), ("Only in the new import", "only_in_b")):
rows = report["articles"][key]
lines += ["", f"## Articles {title.lower()} ({len(rows)})", ""]
lines += [f"* {r['title'][:60]!r} pages {r['pages']} ({r['words']} words)" for r in rows[:25]]
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("baseline", type=Path)
parser.add_argument("new", type=Path)
parser.add_argument("--out", type=Path, help="write the full JSON report here")
parser.add_argument("--md", type=Path, help="write a short Markdown summary here")
args = parser.parse_args(argv)
report = compare(load_magazine(args.baseline), load_magazine(args.new))
if args.out:
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=1), "utf-8")
text = markdown(report)
if args.md:
args.md.write_text(text, "utf-8")
sys.stdout.write(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,108 @@
"""scripts/compare_magazines.py: two folders with known differences give the expected numbers."""
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import compare_magazines as cm # noqa: E402
def blk(i, page, ty, text, bbox, order):
return {"id": i, "page": page, "type": ty, "text": text, "bbox": bbox, "reading_order": order}
def art(i, title, pages, blocks, status="approved", author=""):
return {"id": i, "title": title, "author": author, "pages": pages, "blocks": blocks, "images": [], "status": status}
class CompareTests(unittest.TestCase):
def folder(self, blocks, articles, images=(), quality=None):
d = Path(tempfile.mkdtemp(prefix="ttvc-cmp-"))
self.addCleanup(shutil.rmtree, d, True)
pages = sorted({b["page"] for b in blocks})
(d / "layout.json").write_text(json.dumps({"pages": [{"page": p} for p in pages], "blocks": blocks, "images": list(images)}))
(d / "articles.json").write_text(json.dumps({"articles": articles}))
if quality:
(d / "quality_report.json").write_text(json.dumps(quality))
return d
def base(self):
return self.folder(
[blk("t", 1, "title", "Der Butler", [10, 10, 200, 30], 0), blk("a", 1, "body", "Der erste Absatz des Textes.", [10, 50, 90, 100], 1),
blk("b", 1, "body", "Der zweite Absatz folgt hier.", [120, 50, 90, 100], 2), blk("c", 2, "body", "Und die zweite Seite geht weiter.", [10, 10, 90, 100], 0)],
[art("article-001", "Der Butler", [1, 2], ["t", "a", "b", "c"], author="Jo")])
def test_identical_folders_score_perfectly(self):
report = cm.compare(cm.load_magazine(self.base()), cm.load_magazine(self.base()))
s = report["summary"]
self.assertEqual((s["text_similarity_mean"], s["box_recall_mean"], s["box_precision_mean"], s["type_agreement_mean"], s["order_agreement_mean"]),
(1.0, 1.0, 1.0, 1.0, 1.0))
self.assertEqual((s["pages_flagged"], s["articles"]["matched"], s["articles"]["only_in_baseline"], s["articles"]["only_in_new"]), (0, 1, 0, 0))
self.assertEqual(s["articles"]["same_page_span"], 1)
def test_a_misread_word_a_retyped_box_and_a_changed_reading_order_are_each_reported(self):
new = self.folder(
[blk("t", 1, "title", "Der Butler", [10, 10, 200, 30], 0), blk("a", 1, "body", "Der erste Absatz des Textes.", [10, 50, 90, 100], 2), # order swapped
blk("b", 1, "caption", "Der zweite Absatz folgt hier.", [120, 50, 90, 100], 1), # retyped: no longer narrated
blk("c", 2, "body", "Und die zwelte Seite geht weiter.", [10, 10, 90, 100], 0)], # misread word
[art("article-001", "Der Butler", [1, 2], ["t", "a", "b", "c"], author="Jo")])
report = cm.compare(cm.load_magazine(self.base()), cm.load_magazine(new))
page1 = next(p for p in report["pages"] if p["page"] == 1)
self.assertLess(page1["text_similarity"], 1.0)
self.assertIn("zweite", page1["only_in_a"]) # the retyped box's words are no longer read
self.assertEqual(page1["only_in_b"], "")
self.assertEqual(page1["type_changes"], ["body->caption"])
self.assertLess(page1["order_agreement"], 1.0)
self.assertEqual(set(page1["flags"]), {"text", "types", "order"})
page2 = next(p for p in report["pages"] if p["page"] == 2)
self.assertEqual((page2["only_in_a"], page2["only_in_b"], page2["flags"]), ("zweite", "zwelte", ["text"]))
self.assertEqual(report["summary"]["pages_flagged"], 2)
def test_missing_and_extra_boxes_and_figures_show_up_as_recall_and_precision(self):
new = self.folder(
[blk("t", 1, "title", "Der Butler", [10, 10, 200, 30], 0), blk("a", 1, "body", "Der erste Absatz des Textes.", [10, 50, 90, 100], 1),
blk("x", 1, "body", "Ein ganz anderer Kasten.", [300, 300, 50, 50], 2), blk("c", 2, "body", "Und die zweite Seite geht weiter.", [10, 10, 90, 100], 0)],
[art("article-001", "Der Butler", [1, 2], ["t", "a", "x", "c"], author="Jo")],
images=[{"id": "i", "page": 1, "bbox": [0, 200, 100, 100]}])
page1 = next(p for p in cm.compare(cm.load_magazine(self.base()), cm.load_magazine(new))["pages"] if p["page"] == 1)
self.assertEqual((page1["boxes_a"], page1["boxes_b"], page1["boxes_matched"]), (3, 3, 2))
self.assertAlmostEqual(page1["box_recall"], 2 / 3, places=3)
self.assertAlmostEqual(page1["box_precision"], 2 / 3, places=3)
self.assertIn("boxes_missing_in_new", page1["flags"])
self.assertIn("figures", page1["flags"])
def test_articles_are_matched_by_text_even_when_split_differently(self):
one = self.base()
split = self.folder(
[blk("t", 1, "title", "Der Butler", [10, 10, 200, 30], 0), blk("a", 1, "body", "Der erste Absatz des Textes.", [10, 50, 90, 100], 1),
blk("b", 1, "body", "Der zweite Absatz folgt hier.", [120, 50, 90, 100], 2), blk("c", 2, "body", "Und die zweite Seite geht weiter.", [10, 10, 90, 100], 0)],
[art("article-001", "Der Butler", [1], ["t", "a", "b"], status="detected"), art("article-002", "Page 2", [2], ["c"])])
report = cm.compare(cm.load_magazine(one), cm.load_magazine(split))["articles"]
self.assertEqual(len(report["matched"]), 1) # the bigger piece matches
self.assertFalse(report["matched"][0]["same_pages"])
self.assertEqual([a["title"] for a in report["only_in_b"]], ["Page 2"])
def test_quality_numbers_and_the_markdown_summary(self):
q = {"pages": [{"page": 1, "needs_review": True, "uncovered": [[0, 0, 1, 1]], "clipped": [{}, {}], "overlaps": [], "spelling": ["a"]}]}
new = self.folder(json.loads((self.base() / "layout.json").read_text())["blocks"], [], quality=q)
report = cm.compare(cm.load_magazine(self.base()), cm.load_magazine(new))
self.assertEqual(report["summary"]["quality"]["new"], {"pages_needing_a_decision": 1, "unboxed_areas": 1, "clipped_edges": 2,
"overlapping_pairs": 0, "words_to_check": 1})
text = cm.markdown(report)
self.assertIn("# Magazine comparison", text)
self.assertIn("| clipped edges | 0 | 2 |", text)
def test_the_command_line_writes_json_and_markdown(self):
out = Path(tempfile.mkdtemp(prefix="ttvc-cmp-out-"))
self.addCleanup(shutil.rmtree, out, True)
code = cm.main([str(self.base()), str(self.base()), "--out", str(out / "r.json"), "--md", str(out / "r.md")])
self.assertEqual(code, 0)
self.assertEqual(json.loads((out / "r.json").read_text())["summary"]["pages"], 2)
self.assertTrue((out / "r.md").read_text().startswith("# Magazine comparison"))
if __name__ == "__main__":
unittest.main()

View File

@ -1898,3 +1898,24 @@ class BoxFixesReorderThePageTests(unittest.TestCase):
"blocks": [blk("r1", 110, 10, 0), blk("l1", 10, 10, 1), blk("r2", 110, 80, 2), blk("l2", 10, 80, 3)]} "blocks": [blk("r1", 110, 10, 0), blk("l1", 10, 10, 1), blk("r2", 110, 80, 2), blk("l2", 10, 80, 3)]}
fixed, _, _ = _fix_page_geometry(d, layout, [], 1, {}) fixed, _, _ = _fix_page_geometry(d, layout, [], 1, {})
self.assertEqual([b["id"] for b in sorted(fixed["blocks"], key=lambda b: b["reading_order"])], ["l1", "l2", "r1", "r2"]) self.assertEqual([b["id"] for b in sorted(fixed["blocks"], key=lambda b: b["reading_order"])], ["l1", "l2", "r1", "r2"])
class FastLlmCallTests(unittest.TestCase):
def test_thinking_is_switched_off_and_a_rejecting_backend_is_asked_again_without_the_switch(self):
from unittest.mock import MagicMock, patch
from routes.magazine import _llm_chat_fast
bad, good = MagicMock(status_code=422), MagicMock(status_code=200)
with patch('routes.conversation._post_llm_chat_completion', side_effect=[bad, good]) as post:
out = _llm_chat_fast({"llm_model": "m", "llm_url": "http://x"}, [{"role": "user", "content": "hi"}])
self.assertIs(out, good)
first, second = post.call_args_list[0].args[1], post.call_args_list[1].args[1]
self.assertEqual(first["chat_template_kwargs"], {"enable_thinking": False})
self.assertEqual((first["temperature"], first["max_tokens"]), (0.0, 800))
self.assertNotIn("chat_template_kwargs", second)
def test_a_backend_that_accepts_it_is_called_once(self):
from unittest.mock import MagicMock, patch
from routes.magazine import _llm_chat_fast
with patch('routes.conversation._post_llm_chat_completion', return_value=MagicMock(status_code=200)) as post:
_llm_chat_fast({"llm_model": "m"}, [])
self.assertEqual(post.call_count, 1)

View File

@ -132,6 +132,17 @@ class NamesBrandsAndVocabularyTests(unittest.TestCase):
self.assertEqual([i["token"] for i in found], ["wachst"]) self.assertEqual([i["token"] for i in found], ["wachst"])
class RunTogetherWordTests(unittest.TestCase):
@patched
def test_run_together_words_are_repaired_by_splitting_them_at_a_space(self):
issue = {"token": "dasich", "language": "de", "suggestions": []}
with mock.patch("core.pronunciations._german_spellchecker", return_value=Checker(DE | {"das", "ich", "dasic"})):
self.assertTrue(spelling.accept_agent_fix(issue, "das ich"))
self.assertFalse(spelling.accept_agent_fix(issue, "das ihn")) # letters changed
self.assertFalse(spelling.accept_agent_fix(issue, "d asich")) # a half is not a word
self.assertFalse(spelling.accept_agent_fix({**issue, "token": "dasichx"}, "das ich"))
class AgentGuardTests(unittest.TestCase): class AgentGuardTests(unittest.TestCase):
ISSUE = {"token": "Tesst", "language": "de", "suggestions": ["Tests", "Test"]} ISSUE = {"token": "Tesst", "language": "de", "suggestions": ["Tests", "Test"]}