For the past several months I've been building a back-office system for a trucking company. Around 150 trucks, roughly 70 drivers, routes across the CIS, Turkey and China. The company doesn't own the trucks — it leases them from private owners and provides everything else: the licence, the permits, driver employment, tax filing.
That means paperwork. Lease contracts, technical passports, licences, customs permits, TIR Carnets, driver passports full of border stamps. Every one of them used to be typed in by hand.
So the pitch was obvious: upload a photo, let an LLM read it, fill in the form. And the extraction part does work — modern multimodal models read a scanned contract in Uzbek or Russian surprisingly well.
The part that took real engineering was everything after the model answers. Two rules ended up holding the whole system together.
Rule 1: Extracted values are pre-filled, never saved
The model's output never touches the database directly. It becomes the initial values of a form, and a human presses Save.
This sounds like a UX detail. It isn't. Here's the bug that proved it.
Lease contracts in this business come in two shapes. Some state a monthly rate. Others state a total for the whole term — 11 months, 12 months, sometimes three years. In one test, the model took the contract total and put it in the monthly payment field.
That field isn't cosmetic. It feeds a 12% monthly lease tax that the company pays on the owner's behalf. If that value had been saved silently, the system would have calculated a tax bill 12 times too large — and nobody would have noticed until the accountant did.
Because the value was only pre-filled, the operator saw a monthly payment that was obviously wrong and fixed it. Then I made the form smarter so they wouldn't have to catch it by eye:
# leases/extraction.py
from decimal import Decimal, ROUND_HALF_UP
TOLERANCE = Decimal("1.00")
def prefill_lease_form(extracted: dict) -> dict:
"""Turn model output into form initial values + warnings.
Never writes to the database. The operator confirms everything.
"""
monthly = _to_decimal(extracted.get("monthly_payment"))
total = _to_decimal(extracted.get("contract_total"))
months = extracted.get("term_months")
warnings: list[str] = []
if monthly is None and total and months:
monthly = (total / months).quantize(Decimal("0.01"), ROUND_HALF_UP)
warnings.append("Monthly payment was derived from total ÷ term. Please confirm.")
if monthly and total and months:
if abs(monthly * months - total) > TOLERANCE:
warnings.append(
f"Monthly × term ({monthly * months}) doesn't match the contract total ({total})."
)
return {
"initial": {
"owner_name": extracted.get("owner_name"),
"start_date": extracted.get("start_date"),
"term_months": months,
"monthly_payment": monthly,
},
"warnings": warnings,
}
def _to_decimal(value) -> Decimal | None:
if value in (None, ""):
return None
return Decimal(str(value).replace(" ", "").replace(",", "."))
The model is good at reading. It is not responsible for deciding. Cross-checks like "does monthly × term equal the total?" are cheap, deterministic, and catch exactly the kind of mistake an LLM makes confidently.
Rule 2: Anything with legal or tax consequences lives in the service layer, with tests
The client's number-one requirement wasn't AI at all. It was this:
The owner sets a permit limit per driver (2 to 5, depending on the route). Staff cannot override it. And no new document is issued until the previous one comes back.
Customs permits and TIR Carnets are expensive to lose. A lost Carnet means penalties, and a late return can block the company from getting new ones at all. So this rule isn't a "nice to have" — it protects the business.
It would have been easy to put this check in a view, or in a form's clean() method. But views multiply: there's the web UI, the admin, the Telegram bot, a future mobile app. Every one of those is a door. So the rule lives in exactly one place:
# permits/services.py
from django.db import transaction
from django.utils import timezone
from .models import Driver, Permit
class PermitLimitReached(Exception):
pass
@transaction.atomic
def issue_permit(*, driver_id: int, permit_type: str, issued_by) -> Permit:
# Lock the driver row so two staff members can't race past the limit.
driver = Driver.objects.select_for_update().get(pk=driver_id)
open_permits = Permit.objects.filter(driver=driver, returned_at__isnull=True).count()
if open_permits >= driver.permit_limit:
raise PermitLimitReached(
f"{driver} already holds {open_permits}/{driver.permit_limit} permits. "
"Return one before issuing another."
)
return Permit.objects.create(
driver=driver,
permit_type=permit_type,
issued_by=issued_by,
issued_at=timezone.now(),
)
And permit_limit is editable only by the owner role — enforced in its own service function, not by hiding a field in the UI.
Then the tests, because a rule without a test is a suggestion:
# permits/tests/test_services.py
import pytest
from permits.services import PermitLimitReached, issue_permit
@pytest.mark.django_db
def test_cannot_exceed_owner_set_limit(driver_factory, staff_user):
driver = driver_factory(permit_limit=2)
issue_permit(driver_id=driver.id, permit_type="TIR", issued_by=staff_user)
issue_permit(driver_id=driver.id, permit_type="DAZVOL", issued_by=staff_user)
with pytest.raises(PermitLimitReached):
issue_permit(driver_id=driver.id, permit_type="DAZVOL", issued_by=staff_user)
@pytest.mark.django_db
def test_returning_a_permit_frees_a_slot(driver_factory, staff_user, permit_factory):
driver = driver_factory(permit_limit=1)
permit = issue_permit(driver_id=driver.id, permit_type="TIR", issued_by=staff_user)
permit.mark_returned()
assert issue_permit(driver_id=driver.id, permit_type="TIR", issued_by=staff_user)
What the users taught me
A few things I would not have guessed from my desk:
- Drivers don't have PDFs. Many can't produce one. The upload had to accept phone photos, and multiple of them — a single trip's border stamps can spread across two or three passport pages.
- The model should help catch fraud, not just save typing. The client asked for AI to read stamp dates and reject trips whose declared period doesn't match the passport. That's a better use of the model than data entry.
- The domain is never "two types." Truck configurations, permit types, tax lines — every category I modeled grew a third member once the people who run the business looked at it.
The takeaway
LLMs are great readers and terrible accountants. Let them read. Show a human what they read. Put every rule that can cost money in one tested function — and never let the model press Save.