feat(ocr): fine-tune OCR quality criteria with stricter threshold and head-to-head comparison

- Raise quality acceptance threshold from 65→85 (configurable via TEXT_QUALITY_THRESHOLD)
- Reject text with significant issues (excessive_typos, garbage_characters,
  incoherent_text, fragmented_sentences) even when score is above threshold
  (configurable via TEXT_QUALITY_SIGNIFICANT_ISSUES)
- Add compare_text_quality() for AI-powered head-to-head comparison of
  original embedded text vs fresh OCR output
- Update process_document to pass original text to OCR task for comparison
- Update process_with_ocr to run comparison and keep the higher-quality text
- Add new settings to settings_service.py metadata
- Update docs/ConfigurationGuide.md with new settings
- Add comprehensive tests for new threshold and comparison logic

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-25 13:03:21 +00:00
parent d1c60c5ac5
commit 89d5df71c8
7 changed files with 659 additions and 46 deletions
+99 -5
View File
@@ -8,7 +8,10 @@ task with a multi-engine OCR pipeline that:
2. Merges/cross-checks the results using the configured AI model when more
than one provider is active (see ``OCR_MERGE_STRATEGY``).
3. Writes the best searchable PDF back to the working directory.
4. Hands off to the page-rotation and metadata-extraction pipeline exactly as
4. Optionally compares the OCR output against the original embedded text
(passed as *original_text*) using a head-to-head AI review and keeps the
higher-quality text for downstream processing.
5. Hands off to the page-rotation and metadata-extraction pipeline exactly as
the legacy Azure task did.
"""
@@ -22,20 +25,26 @@ from app.tasks.retry_config import BaseTaskWithRetry
from app.tasks.rotate_pdf_pages import rotate_pdf_pages
from app.utils import log_task_progress
from app.utils.ocr_provider import OCRResult, embed_text_layer, get_ocr_providers, merge_ocr_results
from app.utils.text_quality import compare_text_quality
logger = logging.getLogger(__name__)
@celery.task(base=BaseTaskWithRetry, bind=True)
def process_with_ocr(self, filename: str, file_id: Optional[int] = None):
def process_with_ocr(self, filename: str, file_id: Optional[int] = None, original_text: Optional[str] = None):
"""Run the configured OCR providers on *filename* and continue the pipeline.
When multiple OCR providers are configured the results are merged using the
AI model (or a simpler strategy controlled by ``OCR_MERGE_STRATEGY``).
If *original_text* is provided (the original embedded text that failed the
quality check), the OCR result is compared against it using a head-to-head
AI review. The higher-quality text is passed to downstream tasks.
Args:
filename: Base name of the file inside ``<workdir>/tmp/``.
file_id: Optional database record ID passed through to downstream tasks.
original_text: Optional original embedded text for head-to-head comparison.
"""
task_id = self.request.id
log_task_progress(
@@ -138,22 +147,107 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None):
file_id=file_id,
)
# ----------------------------------------------------------------
# Head-to-head comparison with original embedded text (if provided)
# ----------------------------------------------------------------
final_text = extracted_text
if original_text and original_text.strip() and extracted_text.strip():
logger.info(f"[{task_id}] Original embedded text provided; running head-to-head quality comparison")
log_task_progress(
task_id,
"compare_ocr_quality",
"in_progress",
"Comparing OCR result against original embedded text",
file_id=file_id,
)
try:
comparison = compare_text_quality(original_text, extracted_text)
comparison_detail = (
f"Original score: {comparison.original_score}/100, "
f"OCR score: {comparison.ocr_score}/100, "
f"Preferred: {comparison.preferred}\n"
f"AI explanation: {comparison.explanation}"
)
logger.info(f"[{task_id}] OCR comparison {comparison_detail}")
if comparison.preferred == "original":
# Original text is actually better use it instead of OCR.
final_text = original_text
logger.info(
f"[{task_id}] Original embedded text selected "
f"(original={comparison.original_score} > ocr={comparison.ocr_score})"
)
log_task_progress(
task_id,
"compare_ocr_quality",
"success",
f"Original text preferred (original={comparison.original_score}/100 vs "
f"ocr={comparison.ocr_score}/100)",
file_id=file_id,
detail=comparison_detail,
)
else:
logger.info(
f"[{task_id}] OCR text selected "
f"(preferred={comparison.preferred!r}, "
f"ocr={comparison.ocr_score}, original={comparison.original_score})"
)
log_task_progress(
task_id,
"compare_ocr_quality",
"success",
f"OCR text preferred (ocr={comparison.ocr_score}/100 vs "
f"original={comparison.original_score}/100)",
file_id=file_id,
detail=comparison_detail,
)
except Exception as cmp_exc:
logger.warning(f"[{task_id}] Head-to-head comparison failed ({cmp_exc}); keeping OCR text")
log_task_progress(
task_id,
"compare_ocr_quality",
"skipped",
f"Comparison failed ({cmp_exc}); keeping OCR output",
file_id=file_id,
)
elif original_text is not None:
# original_text was provided but one side is empty pick whichever has content.
if not extracted_text.strip() and original_text.strip():
final_text = original_text
logger.info(f"[{task_id}] OCR returned empty text; falling back to original embedded text")
log_task_progress(
task_id,
"compare_ocr_quality",
"success",
"OCR empty using original embedded text",
file_id=file_id,
)
else:
log_task_progress(
task_id,
"compare_ocr_quality",
"skipped",
"No original text to compare; using OCR output",
file_id=file_id,
)
log_task_progress(
task_id,
"process_with_ocr",
"success",
f"OCR complete for {filename}",
file_id=file_id,
detail=f"Extracted {len(extracted_text)} chars using {len(results)} provider(s)",
detail=f"Extracted {len(extracted_text)} chars using {len(results)} provider(s); "
f"final text length: {len(final_text)} chars",
)
# Continue pipeline: rotate pages (if needed), then extract metadata
rotate_pdf_pages.delay(filename, extracted_text, rotation_data, file_id)
rotate_pdf_pages.delay(filename, final_text, rotation_data, file_id)
return {
"file": filename,
"searchable_pdf": searchable_pdf_path or tmp_file_path,
"cleaned_text": extracted_text,
"cleaned_text": final_text,
"providers_used": [r.provider for r in results],
}