Files
multiarch-docker-images/automatic-pdf-renamer/tests/test_document_suggestions.py
T

309 lines
13 KiB
Python

"""Tests for asynchronous, confidence-gated document suggestions."""
import asyncio
import re
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from app.api import routes
from app.ml import AmbiguousPredictionError, LocalMLPipeline
class FakeDatabase:
def __init__(self, record):
self.record = record
def get_by_id(self, file_id):
return self.record if file_id == self.record.id else None
class FakeRegistry:
def get_additional_fields(self):
return {
"invoice_number": ["INV-123"],
"amount": ["42"],
}
def get_date_extraction_compiled(self):
return [re.compile(r"([0-9]{1,2})/([0-9]{1,2})/([0-9]{4})")]
def get_period_format_config(self, name):
return SimpleNamespace(to_dict=lambda: {})
def get_key_pattern(self, name):
return "example/{company}/{period}/{document_type}"
class FakeMLPipeline(LocalMLPipeline):
def __init__(self, confidence_threshold=None):
super().__init__(confidence_threshold=confidence_threshold)
def _load(self, model_name):
if model_name == "additional_field_values":
return SimpleNamespace(trained_fields=["invoice_number", "amount"])
raise AssertionError(f"Unexpected model load: {model_name}")
def _predict_label(self, model_name, sample):
values = {
"field_emitting_company": ("Suggested Company", 0.96),
"field_document_type": ("invoice", 0.95),
"template_name": ("invoice-template", 0.94),
"period_format": ("monthly", 0.93),
}
if model_name == "template_name" and "invoice_number: INV-123" not in sample:
raise AmbiguousPredictionError("Expected extracted additional field in sample")
if model_name == "template_name" and "LowConfidence" in sample:
raise AmbiguousPredictionError("Below threshold")
return values[model_name]
def predict_additional_groups(self, emitting_company, document_type):
return ["invoice_number", "tags"], 0.91
def predict_additional_values(self, field_names, text, *, allow_partial=False):
if "tags" in field_names:
raise ValueError("The shared extraction model has no training spans for: tags")
return {
field_name: ("INV-123" if field_name == "invoice_number" else "42", 0.92)
for field_name in field_names
}
class DocumentSuggestionTests(unittest.TestCase):
def setUp(self):
self.record = SimpleNamespace(
id="document-1",
extracted_text="Suggested Company invoice\nNumber: INV-123\nAmount: 42\nDate: 20/09/2026",
emitting_company="Old Company",
document_type="receipt",
template_name="old-template",
period_format="monthly",
emission_date="2026-09-20",
optional_fields={"invoice_number": "INV-123"},
original_key="inbox/original.pdf",
)
self.request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
def test_partial_additional_value_extraction_omits_only_low_confidence_fields(self):
pipeline = LocalMLPipeline(confidence_threshold=0.3)
pipeline._load = lambda _name: SimpleNamespace(
predict_values=lambda _text, _fields: {
"appartement": ("", 0.0),
"impots": ("Impots sur le revenu", 0.84),
}
)
result = pipeline.predict_additional_values(
["appartement", "impots"], "document text", allow_partial=True
)
self.assertEqual(result, {"impots": ("Impots sur le revenu", 0.84)})
with self.assertRaises(AmbiguousPredictionError):
pipeline.predict_additional_values(["appartement", "impots"], "document text")
def test_returns_confident_model_suggestions_and_derived_values(self):
with (
patch.object(routes, "_db", return_value=FakeDatabase(self.record)),
patch.object(routes, "_registry", return_value=FakeRegistry()),
patch.object(routes, "LocalMLPipeline", FakeMLPipeline),
patch.object(routes, "format_period_from_emission_date", return_value="2026-09"),
patch.object(routes.logger, "debug") as debug_log,
):
result = asyncio.run(routes.get_document_model_suggestions(
self.request,
"document-1",
{
"emitting_company": "Edited Company",
"document_type": "invoice",
"template_name": "edited-template",
"period_format": "monthly",
"emission_date": "2026-09-20",
"optional_fields": {},
},
current_user=None,
))
suggestions = result["suggestions"]
self.assertEqual(suggestions["emission_date"]["value"], "2026-09-20")
self.assertEqual(suggestions["emission_date"]["source"], "content")
self.assertEqual(suggestions["emitting_company"]["value"], "Suggested Company")
self.assertEqual(
suggestions["optional_fields"]["value"],
{"invoice_number": "INV-123"},
)
self.assertEqual(suggestions["optional_fields"]["source"], "configured")
decisions = next(
call.args[2]
for call in debug_log.call_args_list
if call.args[0] == "API POST /files/%s/suggestions decisions=%s"
)
self.assertIn("configured_matches={'invoice_number': 'INV-123'}", decisions["additional_fields"])
self.assertEqual(suggestions["template_name"]["value"], "invoice-template")
self.assertEqual(suggestions["period_format"]["value"], "monthly")
self.assertEqual(suggestions["period"]["value"], "2026-09")
self.assertEqual(suggestions["current_key"]["value"], "files/example/Edited Company/2026-09/invoice.pdf")
def test_derived_values_use_form_values_not_model_predictions(self):
class ConflictingPipeline(FakeMLPipeline):
def _predict_label(self, model_name, sample):
predictions = {
"field_emitting_company": ("Model Company", 0.96),
"field_document_type": ("model-document", 0.95),
"template_name": ("model-template", 0.94),
"period_format": ("model-period", 0.93),
}
return predictions[model_name]
class TrackingRegistry(FakeRegistry):
def __init__(self):
self.period_format_used = None
self.template_used = None
def get_period_format_config(self, name):
self.period_format_used = name
return SimpleNamespace(to_dict=lambda: {})
def get_key_pattern(self, name):
self.template_used = name
return "{template_name}/{company}/{document_type}/{period_format}/{period}"
registry = TrackingRegistry()
with (
patch.object(routes, "_db", return_value=FakeDatabase(self.record)),
patch.object(routes, "_registry", return_value=registry),
patch.object(routes, "LocalMLPipeline", ConflictingPipeline),
patch.object(routes, "format_period_from_emission_date", return_value="user-date-period"),
):
result = asyncio.run(routes.get_document_model_suggestions(
self.request,
"document-1",
{
"emitting_company": "User Company",
"document_type": "user-document",
"template_name": "user-template",
"period_format": "user-period-format",
"emission_date": "2026-09-20",
},
current_user=None,
))
suggestions = result["suggestions"]
self.assertEqual(suggestions["emitting_company"]["value"], "Model Company")
self.assertEqual(suggestions["document_type"]["value"], "model-document")
self.assertEqual(suggestions["template_name"]["value"], "model-template")
self.assertEqual(suggestions["period_format"]["value"], "model-period")
self.assertEqual(suggestions["period"]["value"], "user-date-period")
self.assertEqual(registry.period_format_used, "user-period-format")
self.assertEqual(registry.template_used, "user-template")
self.assertEqual(
suggestions["current_key"]["value"],
"files/user-template/User Company/user-document/user-period-format/user-date-period.pdf",
)
def test_suggestion_orchestrator_collects_independent_model_results(self):
pipeline = FakeMLPipeline(confidence_threshold=0.3)
suggestions, decisions = pipeline.suggest_document_fields(
self.record.extracted_text,
"Edited Company",
"invoice",
{"invoice_number": "INV-123"},
FakeRegistry().get_additional_fields(),
)
self.assertEqual(suggestions["emitting_company"]["value"], "Suggested Company")
self.assertEqual(suggestions["document_type"]["value"], "invoice")
self.assertEqual(suggestions["optional_fields"]["value"], {"invoice_number": "INV-123"})
self.assertEqual(suggestions["template_name"]["value"], "invoice-template")
self.assertEqual(suggestions["period_format"]["value"], "monthly")
self.assertIn("configured_matches={'invoice_number': 'INV-123'}", decisions["additional_fields"])
def test_configured_value_matching_corrects_partial_case_insensitive_spans(self):
text = "65 RUE RAYMOND LOSSERAND\nTaxe foncière"
self.assertEqual(
LocalMLPipeline.match_configured_additional_value(
text,
"65 RUE RAYMOND",
["65 rue Raymond Losserand", "20 rue Paul Fort"],
),
"65 rue Raymond Losserand",
)
self.assertEqual(
LocalMLPipeline.match_configured_additional_value(
text,
"Taxe",
["Taxe foncière", "Taxe d'habitation"],
),
"Taxe foncière",
)
self.assertIsNone(
LocalMLPipeline.match_configured_additional_value(
"Some unrelated text",
"unrelated",
["Taxe foncière"],
)
)
self.assertIsNone(
LocalMLPipeline.match_configured_additional_value(
"Taxe foncière et Taxe d'habitation",
"Taxe",
["Taxe foncière", "Taxe d'habitation"],
)
)
def test_unavailable_or_low_confidence_predictions_are_omitted(self):
class LowConfidencePipeline(FakeMLPipeline):
def _predict_label(self, model_name, sample):
if model_name == "template_name":
raise AmbiguousPredictionError("Below threshold")
return super()._predict_label(model_name, sample)
with (
patch.object(routes, "_db", return_value=FakeDatabase(self.record)),
patch.object(routes, "_registry", return_value=FakeRegistry()),
patch.object(routes, "LocalMLPipeline", LowConfidencePipeline),
patch.object(routes, "format_period_from_emission_date", return_value="2026-09"),
):
result = asyncio.run(routes.get_document_model_suggestions(
self.request,
"document-1",
{"emission_date": "2026-09-20"},
current_user=None,
))
self.assertNotIn("template_name", result["suggestions"])
self.assertEqual(result["suggestions"]["period_format"]["value"], "monthly")
def test_low_confidence_additional_field_does_not_block_other_suggestions(self):
class PartialConfidencePipeline(FakeMLPipeline):
def predict_additional_groups(self, emitting_company, document_type):
return ["invoice_number", "amount"], 0.91
def predict_additional_values(self, field_names, text, *, allow_partial=False):
if field_names == ["invoice_number"]:
raise AmbiguousPredictionError("invoice number below threshold")
return {"amount": ("42", 0.87)}
with (
patch.object(routes, "_db", return_value=FakeDatabase(self.record)),
patch.object(routes, "_registry", return_value=FakeRegistry()),
patch.object(routes, "LocalMLPipeline", PartialConfidencePipeline),
patch.object(routes, "format_period_from_emission_date", return_value="2026-09"),
):
result = asyncio.run(routes.get_document_model_suggestions(
self.request,
"document-1",
{"emission_date": "2026-09-20"},
current_user=None,
))
suggestions = result["suggestions"]
self.assertEqual(suggestions["optional_fields"]["value"], {"amount": "42"})
self.assertEqual(suggestions["optional_fields"]["source"], "configured")
self.assertEqual(suggestions["template_name"]["value"], "invoice-template")
self.assertEqual(suggestions["period_format"]["value"], "monthly")
if __name__ == "__main__":
unittest.main()