feat(magazine): run the whole-issue Auto-fix pass automatically after import
A fresh import used to stop once laid out, leaving missed text, unverified OCR and provable misspellings for a separate manual click. _ingest_sync now runs _quality_run_sync itself, under the same job, so the ingest progress bar carries through it and the reviewer opens an already-worked magazine. An Auto-fix failure is reported as an ingest failure without losing the ingest's own result. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f2dfca89a
commit
f0b5d5aa68
@ -12,6 +12,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
### 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.
|
||||
- **A fresh import now finishes fully worked, not just laid out.** Ingestion runs the whole-issue Auto-fix pass (find missed text, re-read suspicious OCR, fix provable misspellings, link continuation pages, audit box coverage) itself as its last step, under the same job the ingest progress bar already follows -- no separate "Auto-fix all pages" click needed first. An Auto-fix failure is reported as an ingest failure (with the reason) rather than a silent gap; the ingest's own result (pages, articles) is kept either way.
|
||||
|
||||
### Fixed
|
||||
- Vision OCR: a refusal sentence the engine hallucinates over an unreadable photo or screenshot ("The image is too blurry to recognize...") is no longer narrated as page text -- discarded when the same sentence repeats across 3+ regions, confirmed live on c't KI Wissen 2026 pages 82, 87 and 124 (29 and 122 hallucinated regions dropped on re-OCR).
|
||||
|
||||
@ -290,7 +290,7 @@ unreviewed boundaries may include a neighbouring article or an advert.
|
||||
**Where an article ends:** the last box says *article ends* only when the article is signed off (`(rah) et`) or a headline/deck/byline follows it (same page or top of the next). Otherwise it shows *no end marker — link page N ›*; click it to add the text of that page that carries the article on (Undo reverses it).
|
||||
**Reading order:** applied automatically after import, *OCR again* and *Fix this page automatically*; pages whose order you set by hand keep it (*Automatic order* releases a page).
|
||||
**Text flow:** green arrows and chips on the page show where the article carries on: an arrow to the next box below, a curved *next column* arrow, and a chip *continues on page N ›* / *‹ from page N*, plus *article starts / ends*. Tick or untick **Show text flow** to hide them. In the reading view a small marker (`↓`, `col ›`, `p.N ›`) sits at the word where a paragraph crosses a box, column or page. The markers are labelled for screen readers.
|
||||
**Whole magazine:** **Auto-fix all pages** (contents view, formerly *Quality check / run*) applies the same fixes to every page and then lists the pages that still need a look (purple badges).
|
||||
**Whole magazine:** a fresh import now runs this automatically as its last step -- the magazine you open is already as fixed as the machine can make it. **Auto-fix all pages** (contents view, formerly *Quality check / run*) repeats it on demand (for instance after a manual box edit) and lists the pages that still need a look (purple badges).
|
||||
**Errors:** a failed fix shows the reason and changes nothing. **Offline / privacy:** the box fixes and the audit are local; the OCR re-read and spelling steps use only the engines you configured.
|
||||
|
||||
**Colours in the article list:** a **blue** row with a *not reviewed* pill is an article nobody has approved or rejected yet; a green edge means approved.
|
||||
@ -304,7 +304,7 @@ unreviewed boundaries may include a neighbouring article or an advert.
|
||||
|
||||
### Less manual work: what happens automatically
|
||||
|
||||
At import, and whenever you press **Auto-fix all pages**: boxes are fitted, inset pull quotes found, wrapped paragraphs cut, continuation pages **linked into the article running from the page before**, missed text added, suspicious words re-read and (with a language model) provable misspellings fixed. The report then lists only the pages that need a *decision about their boxes*, with **Start with page N**; pages that only have words to check are collapsed and optional. Articles that gained pages go back to *detected*, so re-approve them (the batch tools in *Select* do many at once). A backup of the layout is kept.
|
||||
At import (as its automatic last step, not a separate click any more), and whenever you press **Auto-fix all pages**: boxes are fitted, inset pull quotes found, wrapped paragraphs cut, continuation pages **linked into the article running from the page before**, missed text added, suspicious words re-read and (with a language model) provable misspellings fixed. The report then lists only the pages that need a *decision about their boxes*, with **Start with page N**; pages that only have words to check are collapsed and optional. Articles that gained pages go back to *detected*, so re-approve them (the batch tools in *Select* do many at once). A backup of the layout is kept. The ingest status bar keeps showing progress ("Checking page N of M: …") through this step, so a fresh import simply takes longer before it says done -- an Auto-fix failure (for example an unreachable OCR engine) is reported as an ingest failure rather than a silent gap, but nothing already imported is lost.
|
||||
**When the automatic link misses:** open the article in review and use **Link to page N** in the panel: *Article continues there* adds the text that belongs on that page (from the paragraph that carries the sentence on, up to the next headline), *Article starts there* adds the opening. Added text is ticked, so untick what does not belong. **Undo** reverses it.
|
||||
|
||||
### Clean up boxes: fitting boxes to the text
|
||||
|
||||
@ -380,8 +380,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
|
||||
meta = _read_meta(d)
|
||||
meta["pageCount"] = layout.get("page_count", 0)
|
||||
meta["ingest"] = {
|
||||
"status": "done",
|
||||
"finished": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"status": "running", # extended below by the automatic Auto-fix pass
|
||||
"job_id": job_id,
|
||||
"articles": len(articles),
|
||||
"ocr_pages": sum(
|
||||
1 for b in layout.get("blocks", []) if b.get("source") == "ocr"
|
||||
@ -389,9 +389,25 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
|
||||
"vision_ocr_pages": layout.get("vision_ocr_pages", 0),
|
||||
}
|
||||
_write_meta(d, meta)
|
||||
background_job_update(job_id, stage="done", done=True,
|
||||
detail=f"{len(articles)} articles detected",
|
||||
result={"articles": len(articles)})
|
||||
|
||||
# A fresh import used to stop here, laid out but not worked -- every page's missed
|
||||
# text, suspicious OCR and provable misspellings were left for a separate, manual
|
||||
# "Auto-fix all pages" click. Running that same pass (_quality_run_sync) right away,
|
||||
# under this same job, means the reviewer opens a magazine that is already as fixed
|
||||
# as the machine can make it, and is shown only what still needs a human decision.
|
||||
background_job_update(job_id, stage="quality-run", detail="Starting the automatic Auto-fix pass")
|
||||
_quality_run_sync(doc_id, job_id, _load_settings())
|
||||
# _quality_run_sync catches its own errors rather than raising, so the ingest itself
|
||||
# (already saved above) is not lost over an Auto-fix failure -- but it must not be
|
||||
# reported as fully done either.
|
||||
job_after = background_job_get(job_id) or {}
|
||||
if job_after.get("stage") == "failed":
|
||||
meta["ingest"]["status"] = "failed"
|
||||
meta["ingest"]["error"] = str(job_after.get("error") or "Auto-fix pass failed")[:500]
|
||||
else:
|
||||
meta["ingest"]["status"] = "done"
|
||||
meta["ingest"]["finished"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
_write_meta(d, meta)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to UI
|
||||
meta = _read_meta(d)
|
||||
meta["ingest"] = {"status": "failed", "error": str(exc)[:500]}
|
||||
|
||||
@ -600,7 +600,16 @@ async function pollIngest() {
|
||||
}
|
||||
}
|
||||
if (ing.status !== 'running') {
|
||||
if (ing.status === 'done') await magRefreshArticles();
|
||||
if (ing.status === 'done') {
|
||||
// Ingestion now runs the same automatic Auto-fix pass "Auto-fix all pages" runs by
|
||||
// hand before reporting done (see _ingest_sync) -- show its summary here too, exactly
|
||||
// as magQualityRun does on its own completion, so the reviewer sees what was fixed
|
||||
// and what still needs a decision without an extra click.
|
||||
await magRefreshArticles();
|
||||
state.layout = null;
|
||||
try { magRenderQualityReport(await api('/api/magazine/' + state.doc.id + '/quality/report')); } catch (_) { /* no report yet */ }
|
||||
await magLoadQuality(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
@ -1453,6 +1453,7 @@ class IngestSyncVisualRefreshTests(unittest.TestCase):
|
||||
patch.object(rm.mag, "save_layout"), \
|
||||
patch.object(rm.mag, "build_articles", return_value=[]), \
|
||||
patch.object(rm.mag, "save_articles"), \
|
||||
patch.object(rm, "_quality_run_sync"), \
|
||||
patch.object(rm, "background_job_update"), \
|
||||
patch.object(rm, "_write_meta"):
|
||||
rm._ingest_sync("doc1", "job1", "eng", 150, None, None)
|
||||
@ -1473,6 +1474,7 @@ class IngestSyncVisualRefreshTests(unittest.TestCase):
|
||||
def semantic(_layout, _settings, page=None, doc_dir=None, on_page=None):
|
||||
on_page(1)
|
||||
|
||||
written = {}
|
||||
with patch.object(rm.mag, "extract_pages", return_value=layout), \
|
||||
patch.object(rm, "_recognise_advertisements"), \
|
||||
patch.object(rm, "_recognise_semantic_layout", side_effect=semantic), \
|
||||
@ -1481,12 +1483,12 @@ class IngestSyncVisualRefreshTests(unittest.TestCase):
|
||||
patch.object(rm.mag, "save_layout") as save_layout, \
|
||||
patch.object(rm.mag, "build_articles", return_value=[]), \
|
||||
patch.object(rm.mag, "save_articles"), \
|
||||
patch.object(rm, "background_job_update") as update, \
|
||||
patch.object(rm, "_write_meta"):
|
||||
patch.object(rm, "_quality_run_sync"), \
|
||||
patch.object(rm, "background_job_update"), \
|
||||
patch.object(rm, "_write_meta", side_effect=lambda d, m: written.update(m)):
|
||||
rm._ingest_sync("doc1", "job1", "eng", 150, None, None)
|
||||
save_layout.assert_called_once()
|
||||
done = [c.kwargs for c in update.call_args_list if c.kwargs.get("done")]
|
||||
self.assertEqual(done[-1]["stage"], "done") # never surfaced as an ingest failure
|
||||
self.assertEqual(written["ingest"]["status"], "done") # never surfaced as an ingest failure
|
||||
shutil.rmtree(lib, ignore_errors=True)
|
||||
|
||||
|
||||
@ -1517,6 +1519,7 @@ class IngestSyncDuplicateRecheckTests(unittest.TestCase):
|
||||
patch.object(rm.mag, "save_layout"), \
|
||||
patch.object(rm.mag, "build_articles", return_value=[]), \
|
||||
patch.object(rm.mag, "save_articles"), \
|
||||
patch.object(rm, "_quality_run_sync"), \
|
||||
patch.object(rm, "background_job_update"), \
|
||||
patch.object(rm, "_write_meta"):
|
||||
rm._ingest_sync("doc1", "job1", "eng", 150, None, None)
|
||||
@ -1526,6 +1529,68 @@ class IngestSyncDuplicateRecheckTests(unittest.TestCase):
|
||||
shutil.rmtree(lib, ignore_errors=True)
|
||||
|
||||
|
||||
class IngestSyncAutoQualityRunTests(unittest.TestCase):
|
||||
"""A fresh import used to stop once laid out, leaving every page's missed text,
|
||||
suspicious OCR and provable misspellings for a separate, manual "Auto-fix all pages"
|
||||
click. It now runs that same pass itself, under the same job, so the reviewer opens an
|
||||
already-worked magazine."""
|
||||
|
||||
def _ingest(self, quality_run):
|
||||
from unittest.mock import patch, call
|
||||
import routes.magazine as rm
|
||||
with patch.object(rm, "_LIB", Path(tempfile.mkdtemp(prefix="ttvc-lib-"))) as lib:
|
||||
d = lib / "doc1"
|
||||
d.mkdir(parents=True)
|
||||
(d / "source.pdf").write_bytes(b"x")
|
||||
layout = {"pages": [{"page": 1, "width": 100, "height": 100}], "blocks": [], "images": []}
|
||||
|
||||
def semantic(_layout, _settings, page=None, doc_dir=None, on_page=None):
|
||||
on_page(1)
|
||||
|
||||
written = {}
|
||||
with patch.object(rm.mag, "extract_pages", return_value=layout), \
|
||||
patch.object(rm, "_recognise_advertisements"), \
|
||||
patch.object(rm, "_recognise_semantic_layout", side_effect=semantic), \
|
||||
patch.object(rm.mag, "refresh_visual_regions"), \
|
||||
patch.object(rm.mag, "clean_up_regions", return_value=(layout, {})), \
|
||||
patch.object(rm, "_fix_page_geometry", return_value=(layout, {}, False)), \
|
||||
patch.object(rm.mag, "save_layout"), \
|
||||
patch.object(rm.mag, "build_articles", return_value=[{"id": "a1"}]), \
|
||||
patch.object(rm.mag, "save_articles"), \
|
||||
patch.object(rm, "_quality_run_sync", side_effect=quality_run) as qrun, \
|
||||
patch.object(rm, "background_job_update"), \
|
||||
patch.object(rm, "_write_meta", side_effect=lambda d, m: written.update(m)):
|
||||
rm._ingest_sync("doc1", "job1", "eng", 150, None, None)
|
||||
shutil.rmtree(lib, ignore_errors=True)
|
||||
return written, qrun
|
||||
|
||||
def test_the_quality_run_is_called_with_the_ingest_jobs_own_id(self):
|
||||
written, qrun = self._ingest(lambda doc_id, job_id, settings: None)
|
||||
qrun.assert_called_once()
|
||||
self.assertEqual(qrun.call_args.args[:2], ("doc1", "job1"))
|
||||
|
||||
def test_success_is_reported_as_ingest_done(self):
|
||||
import routes.magazine as rm
|
||||
from unittest.mock import patch
|
||||
with patch.object(rm, "background_job_get", return_value={"stage": "done", "done": True}):
|
||||
written, _ = self._ingest(lambda doc_id, job_id, settings: None)
|
||||
self.assertEqual(written["ingest"]["status"], "done")
|
||||
self.assertIn("finished", written["ingest"])
|
||||
|
||||
def test_a_quality_run_failure_is_reported_without_losing_the_ingest(self):
|
||||
"""_quality_run_sync catches its own errors rather than raising -- the ingest itself
|
||||
(articles already saved) must not be reported as failed too, but "done" would be a
|
||||
lie, so the ingest status becomes "failed" with the Auto-fix pass's own error."""
|
||||
import routes.magazine as rm
|
||||
from unittest.mock import patch
|
||||
with patch.object(rm, "background_job_get",
|
||||
return_value={"stage": "failed", "done": True, "error": "vision engine unreachable"}):
|
||||
written, _ = self._ingest(lambda doc_id, job_id, settings: None)
|
||||
self.assertEqual(written["ingest"]["status"], "failed")
|
||||
self.assertIn("vision engine unreachable", written["ingest"]["error"])
|
||||
self.assertEqual(written["ingest"]["articles"], 1) # the ingest's own result is kept
|
||||
|
||||
|
||||
class InterruptedIngestTests(unittest.TestCase):
|
||||
def test_an_ingest_killed_by_a_restart_is_reported_as_failed_not_running_for_ever(self):
|
||||
import asyncio
|
||||
|
||||
Loading…
Reference in New Issue
Block a user