mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
153 lines
5.9 KiB
Python
153 lines
5.9 KiB
Python
"""Tests for local ML inference and confidence-gated routing decisions."""
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from app.ml import LocalMLPipeline, AmbiguousPredictionError, ModelUnavailableError
|
|
|
|
|
|
class FakeModel:
|
|
def __init__(self, label, confidence=0.95):
|
|
self.label = label
|
|
self.confidence = confidence
|
|
|
|
def predict(self, samples):
|
|
return [self.label]
|
|
|
|
def predict_proba(self, samples):
|
|
return [[self.confidence, 1.0 - self.confidence]]
|
|
|
|
|
|
class FakeSharedValuesModel:
|
|
def predict_values(self, text, fields):
|
|
return {field: (f"value-{field}", 0.95) for field in fields}
|
|
|
|
|
|
class FakeGroupsModel:
|
|
def __init__(self, groups, confidence=0.95):
|
|
self.groups = groups
|
|
self.confidence = confidence
|
|
|
|
def predict(self, samples):
|
|
return [self.groups]
|
|
|
|
def predict_proba(self, samples):
|
|
return [[self.confidence, 1.0 - self.confidence]]
|
|
|
|
|
|
class LocalMLPipelineTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.model_dir = Path(self.temp_dir.name)
|
|
|
|
def tearDown(self):
|
|
self.temp_dir.cleanup()
|
|
|
|
def pipeline_with(self, models, threshold=0.8):
|
|
active = {}
|
|
for name in models:
|
|
version = "v1"
|
|
artifact = self.model_dir / "versions" / name / f"{version}.joblib"
|
|
artifact.parent.mkdir(parents=True, exist_ok=True)
|
|
artifact.touch()
|
|
active[name] = version
|
|
(self.model_dir / "active_models.json").write_text(json.dumps(active))
|
|
pipeline = LocalMLPipeline(str(self.model_dir), threshold)
|
|
loader = patch("joblib.load", side_effect=lambda path: models[Path(path).parent.name])
|
|
loader.start()
|
|
self.addCleanup(loader.stop)
|
|
return pipeline
|
|
|
|
def test_common_fields_are_predicted_by_models(self):
|
|
models = {
|
|
"field_emitting_company": FakeModel("Example Energy"),
|
|
"field_document_type": FakeModel("invoice"),
|
|
}
|
|
pipeline = self.pipeline_with(models)
|
|
fields = pipeline.extract_initial_fields("document text")
|
|
self.assertEqual(fields["emitting_company"][0], "Example Energy")
|
|
self.assertEqual(fields["document_type"][0], "invoice")
|
|
self.assertEqual(set(fields), {"emitting_company", "document_type"})
|
|
|
|
def test_predict_labels_keeps_successful_fields_when_another_is_ambiguous(self):
|
|
pipeline = self.pipeline_with({
|
|
"field_emitting_company": FakeModel("Example Energy", 0.4),
|
|
"field_document_type": FakeModel("invoice", 0.95),
|
|
})
|
|
|
|
predictions, errors = pipeline.predict_labels({
|
|
"emitting_company": ("field_emitting_company", "document text"),
|
|
"document_type": ("field_document_type", "document text"),
|
|
})
|
|
|
|
self.assertEqual(predictions, {"document_type": ("invoice", 0.95)})
|
|
self.assertIsInstance(errors["emitting_company"], AmbiguousPredictionError)
|
|
|
|
def test_extract_initial_fields_remains_strict(self):
|
|
pipeline = self.pipeline_with({
|
|
"field_emitting_company": FakeModel("Example Energy", 0.4),
|
|
"field_document_type": FakeModel("invoice", 0.95),
|
|
})
|
|
|
|
with self.assertRaises(AmbiguousPredictionError):
|
|
pipeline.extract_initial_fields("document text")
|
|
|
|
def test_additional_groups_are_predicted_with_confidence(self):
|
|
pipeline = self.pipeline_with({
|
|
"additional_field_groups": FakeGroupsModel(["appartement", "impots"]),
|
|
})
|
|
|
|
groups, confidence = pipeline.predict_additional_groups("Impots", "Avis")
|
|
|
|
self.assertEqual(groups, ["appartement", "impots"])
|
|
self.assertEqual(confidence, 0.95)
|
|
|
|
def test_low_confidence_prediction_is_rejected(self):
|
|
pipeline = self.pipeline_with({"field_document_type": FakeModel("invoice", 0.4)})
|
|
with self.assertRaises(AmbiguousPredictionError):
|
|
pipeline._predict_label("field_document_type", "document text")
|
|
|
|
def test_shared_model_extracts_multiple_fields(self):
|
|
pipeline = self.pipeline_with({"additional_field_values": FakeSharedValuesModel()}, threshold=0.8)
|
|
values = pipeline.predict_additional_values(["invoice_number", "account_number"], "document text")
|
|
self.assertEqual(values["invoice_number"][0], "value-invoice_number")
|
|
self.assertEqual(values["account_number"][0], "value-account_number")
|
|
|
|
def test_missing_model_is_not_replaced_by_rules(self):
|
|
pipeline = LocalMLPipeline(str(self.model_dir), 0.8)
|
|
with self.assertRaises(ModelUnavailableError):
|
|
pipeline._predict_label("field_document_type", "invoice text")
|
|
|
|
def test_low_confidence_additional_field_is_rejected(self):
|
|
pipeline = self.pipeline_with({"additional_field_values": FakeSharedValuesModel()}, threshold=0.99)
|
|
with self.assertRaises(AmbiguousPredictionError):
|
|
pipeline.predict_additional_values(["invoice_number"], "document text")
|
|
|
|
def test_template_and_period_format_are_predicted_together(self):
|
|
pipeline = self.pipeline_with({
|
|
"template_name": FakeModel("invoice-template", 0.94),
|
|
"period_format": FakeModel("monthly", 0.91),
|
|
})
|
|
|
|
result = pipeline.predict_template_and_period_format(
|
|
"Example Energy", "invoice", {"invoice_number": "INV-123"}
|
|
)
|
|
|
|
self.assertEqual(result, ("invoice-template", 0.94, "monthly", 0.91))
|
|
|
|
def test_template_and_period_format_remain_strict(self):
|
|
pipeline = self.pipeline_with({
|
|
"template_name": FakeModel("invoice-template", 0.4),
|
|
"period_format": FakeModel("monthly", 0.91),
|
|
})
|
|
|
|
with self.assertRaises(AmbiguousPredictionError):
|
|
pipeline.predict_template_and_period_format("Example Energy", "invoice", {})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|