mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
309 lines
13 KiB
Python
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()
|