writing/tutorial/2026/05
TutorialMay 6, 2026·9 min read

AI Lab Operations Dashboard: Sample Lifecycle Automation for ISO 17025 Certification Labs

An architecture-led tutorial for small certification labs: replace the paper trail and Excel registers with a five-state sample lifecycle, AI agents for intake classification and anomaly flagging, signed multilingual PDF certificates, and an ISO 17025-grade audit log — under $40/month in infrastructure, deployed in under three weeks.

The paper trail is the bottleneck. This tutorial walks through the architecture of a sample-lifecycle dashboard for ISO 17025 certification labs: a five-state machine, AI agents for intake and anomaly detection, signed multilingual PDF certificates, and an immutable audit log — built on Postgres and a single Python service, with monthly costs under $40.

The Paper Trail Problem

Walk into any small certification lab in the MENA region — gem labs, water quality labs, food safety labs, calibration shops — and you will find the same artefact: a leather-bound register, an Excel sheet on a shared drive, and a stack of duplicate carbon-copy intake slips. Samples move through the lab faster than the registers can be updated. By Friday afternoon, three different versions of the truth exist:

  • The intake slip in the receptionist's drawer
  • The technician's bench notebook
  • The Excel sheet the lab director updates on Sunday evening

ISO 17025 §7.5 requires "technical records" sufficient to reconstruct every test. ISO 17025 §8.4 requires "control of records" with retention, traceability, and protection against loss or alteration. A paper-and-Excel system technically satisfies neither, and every external audit ends with the same finding: records are not consistently traceable to the original sample.

This is not a process problem. It is a data architecture problem. The fix is to model the sample lifecycle as an explicit state machine, store every transition in an append-only log, and let humans plus a small AI layer move samples through the states.

This tutorial is the lab-operations companion to our pillar piece on AI M&E dashboards for MENA NGOs. The reasoning about custom dashboards versus SaaS is the same; the domain — and the compliance constraints — are different.


What You Will Build

By the end of this tutorial you will have the architecture for:

  1. A five-state sample lifecycle (intake → testing → reporting → delivery → archive) implemented as a Python state machine backed by Postgres
  2. An AI agent layer that classifies samples at intake, flags anomalies on test results, and drafts certificate bodies in three languages
  3. A signed PDF certificate engine producing EN / FR / AR certificates from a single data record
  4. An immutable audit log mapped to ISO 17025 §7 and §8 clauses
  5. A monthly operating cost in the range of $30 to $40, with the only paid component being VPS and LLM API calls

This is more architecture-heavy than the KoboToolbox dashboard tutorial, because compliance constraints dominate. The code samples are deliberately minimal — the harder work is the model, not the syntax.


The Sample Lifecycle: Five States

Every certification lab we have built software for collapses cleanly to five states. The names change by domain — a gem lab calls the third state "report drafting" while a water lab calls it "result validation" — but the transitions are identical.

stateDiagram-v2
  [*] --> Intake
  Intake --> Testing: technician_assigned
  Testing --> Reporting: tests_complete
  Reporting --> Delivery: report_signed
  Delivery --> Archive: client_received
  Reporting --> Testing: retest_required
  Testing --> Intake: sample_rejected
  Archive --> [*]

The two backward transitions are the ones that matter for compliance:

  • Reporting → Testing when an anomaly check fails or a senior technician requests a re-test
  • Testing → Intake when the sample is rejected (wrong category, insufficient quantity, contaminated)

Both transitions must be logged with a reason code. Auditors will ask.

Here is the minimal Python state machine. It is intentionally boring — a function per transition, every transition writes to the audit log before mutating the sample row.

# lifecycle.py
from datetime import datetime, timezone
from sqlalchemy import text
 
VALID_TRANSITIONS = {
    "intake":    {"testing", "rejected"},
    "testing":   {"reporting", "intake"},
    "reporting": {"delivery", "testing"},
    "delivery":  {"archive"},
    "archive":   set(),
    "rejected":  set(),
}
 
def transition(sample_id: str, to_state: str, actor: str,
               reason: str | None, conn) -> None:
    row = conn.execute(
        text("SELECT state FROM samples WHERE id = :id FOR UPDATE"),
        {"id": sample_id},
    ).fetchone()
    if row is None:
        raise ValueError(f"sample {sample_id} not found")
 
    from_state = row.state
    if to_state not in VALID_TRANSITIONS[from_state]:
        raise ValueError(f"illegal transition {from_state} -> {to_state}")
 
    # Audit log FIRST — append-only, never updated.
    conn.execute(text("""
        INSERT INTO audit_log (sample_id, from_state, to_state,
                               actor, reason, occurred_at)
        VALUES (:s, :f, :t, :a, :r, :at)
    """), {
        "s": sample_id, "f": from_state, "t": to_state,
        "a": actor, "r": reason,
        "at": datetime.now(timezone.utc),
    })
 
    conn.execute(
        text("UPDATE samples SET state = :t, updated_at = now() WHERE id = :s"),
        {"t": to_state, "s": sample_id},
    )

That is the whole lifecycle layer. Everything else — the dashboard, the AI agents, the certificate engine — reads from samples and audit_log and calls transition() to move samples forward.


The Schema, in Five Tables

CREATE TABLE samples (
    id            TEXT PRIMARY KEY,
    received_at   TIMESTAMPTZ NOT NULL,
    client_id     TEXT NOT NULL,
    category      TEXT NOT NULL,
    state         TEXT NOT NULL DEFAULT 'intake',
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE test_results (
    sample_id     TEXT NOT NULL REFERENCES samples(id),
    metric        TEXT NOT NULL,
    value         NUMERIC NOT NULL,
    unit          TEXT NOT NULL,
    technician    TEXT NOT NULL,
    measured_at   TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (sample_id, metric)
);
 
CREATE TABLE audit_log (
    id            BIGSERIAL PRIMARY KEY,
    sample_id     TEXT NOT NULL,
    from_state    TEXT NOT NULL,
    to_state      TEXT NOT NULL,
    actor         TEXT NOT NULL,
    reason        TEXT,
    occurred_at   TIMESTAMPTZ NOT NULL
);
 
CREATE TABLE agent_flags (
    id            BIGSERIAL PRIMARY KEY,
    sample_id     TEXT NOT NULL REFERENCES samples(id),
    kind          TEXT NOT NULL,         -- 'classification' | 'anomaly' | 'draft'
    payload       JSONB NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    resolved_by   TEXT,
    resolved_at   TIMESTAMPTZ
);
 
CREATE TABLE certificates (
    sample_id     TEXT PRIMARY KEY REFERENCES samples(id),
    pdf_en        BYTEA,
    pdf_fr        BYTEA,
    pdf_ar        BYTEA,
    signature_sig BYTEA NOT NULL,        -- detached PGP signature
    signed_by     TEXT NOT NULL,
    signed_at     TIMESTAMPTZ NOT NULL
);

Five tables. No frameworks. This is the entire data model for a working ISO 17025 lab. The simplicity is deliberate — auditors can read this schema and understand the lab's record-keeping in under five minutes.


The AI Agent Layer

Three agents earn their keep in lab operations. None of them mutate the test record.

1. Intake Classification

The receptionist photographs the intake slip or scans a paper form. The agent extracts the client name, sample category, and requested tests, then proposes a routing — which bench, which technician, which test battery. The technician confirms in one click.

# agents.py
import anthropic, base64, json
from sqlalchemy import text
 
client = anthropic.Anthropic()
 
def classify_intake(sample_id: str, photo_path: str, conn) -> None:
    with open(photo_path, "rb") as f:
        img_b64 = base64.standard_b64encode(f.read()).decode()
    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=400,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {
                    "type": "base64", "media_type": "image/jpeg", "data": img_b64,
                }},
                {"type": "text", "text":
                    "Extract from this lab intake slip: client_name, "
                    "category, requested_tests (list). Return JSON only."},
            ],
        }],
    )
    payload = json.loads(msg.content[0].text)
    conn.execute(text("""
        INSERT INTO agent_flags (sample_id, kind, payload)
        VALUES (:s, 'classification', :p)
    """), {"s": sample_id, "p": json.dumps(payload)})

Cost: about $0.01 per intake at current Sonnet pricing. The technician sees the proposed classification next to a "confirm" button.

2. Anomaly Flagging on Test Results

When a technician enters a value that falls outside the expected band for the sample category, the agent flags it before the report is drafted. The rules are simple statistical bands maintained per category — refractive index between 1.50 and 1.60 for a given stone family, conductivity under 800 µS/cm for potable water, and so on. The agent does not override; it surfaces.

3. Certificate Body Drafting

Once the technician finalises the test results, the agent generates the prose body of the certificate in EN, FR and AR from the structured data, using the lab's house style as a system prompt. The senior technician reviews and signs. End-to-end: about 30 seconds of agent time, replacing 20 minutes of copy-paste from prior certificates.

For more on agent layers that draft but never auto-submit, see our companion piece on GitLab AI PM/QA dashboards.


Signed Multilingual PDFs

The certificate is the lab's product. It must be visually identical to the legacy paper version, cryptographically signed, and reproducible from the database years later.

# certificate.py
import subprocess, tempfile, pathlib
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
 
env = Environment(loader=FileSystemLoader("templates"))
 
def render_certificate(sample_id: str, lang: str, data: dict) -> bytes:
    tmpl = env.get_template(f"certificate_{lang}.html.j2")
    html = tmpl.render(**data, lang=lang, dir="rtl" if lang == "ar" else "ltr")
    return HTML(string=html).write_pdf()
 
def sign_pdf(pdf_bytes: bytes, signing_key_id: str) -> bytes:
    with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
        f.write(pdf_bytes); f.flush()
        result = subprocess.run(
            ["gpg", "--detach-sign", "--armor",
             "--local-user", signing_key_id, "-o", "-", f.name],
            capture_output=True, check=True,
        )
        return result.stdout

One Jinja2 template per language, one PGP signing key per senior technician. The signature is stored alongside the PDF in certificates.signature_sig. Verifying a certificate years later is a one-line gpg --verify call against the stored bytes — no SaaS dependency, no expired vendor account, no migrated database to recover.


ISO 17025 Mapping

Auditors do not care about your tech stack. They care about clause coverage. Here is how the five tables map to the ISO 17025 clauses inspectors actually look for:

ISO 17025 ClauseImplementation in this dashboard
§7.4 Handling of test itemssamples table state column + audit_log transitions
§7.5 Technical recordstest_results table, append-only, technician + timestamp per row
§7.7 Ensuring validity of resultsagent_flags of kind 'anomaly', resolved_by tracked
§7.8 Reporting of resultscertificates table, signed PDFs in three languages
§8.3 Control of management systemVersion-controlled templates and migrations in Git
§8.4 Control of recordsaudit_log append-only + nightly Postgres backup to S3
§8.5 Actions to address risksAnomaly flag SLA dashboard — open flags over 24h escalate

Print this table on the wall next to the lab door. When the auditor arrives, you have already answered the questions.

A practical detail many labs miss: §8.4 also requires that records be retrievable, not just retained. A dusty backup tape in a drawer is not a record system. The dashboard described here makes every certificate queryable by sample ID, client, date range, or technician — and renders the original PDF on demand from the signed bytes in certificates.pdf_*. That single capability has more impact on audit outcomes than any other line in the table above. We have watched auditors switch from "show me your record-keeping" to "show me anything you handled in March 2024" and seen labs collapse under the second question even when they passed the first. The dashboard makes both questions trivial.


A Real Anonymised Pattern

A Tunisian ISO 17025-accredited lab we worked with processes around 8,000 samples per year with three technicians and a senior reviewer. Before the dashboard, average report turnaround was two business days — driven almost entirely by the manual certificate-drafting step and the back-and-forth on the paper register. After the dashboard went live with the architecture above:

  • Average turnaround for standard certificates dropped from two days to under two minutes of human attention per sample, with the agent draft confirmed in one review pass
  • ISO 17025 surveillance audit findings on traceability dropped from seven non-conformities to zero at the next cycle
  • Technician overtime in peak season fell by roughly 40 percent, because the dashboard absorbed the registration and report-drafting load
  • The lab's monthly software cost remained under $40 — a single VPS, Postgres, and metered LLM calls

The build was three weeks elapsed, parallel-run with the paper register for a fourth week before cutover. The lab director's verification gate was simple: the dashboard had to produce a byte-identical certificate to the legacy template for ten randomly chosen historical samples. Once that passed, the paper register was archived.

The second-order effects took us by surprise. With the dashboard live, the senior reviewer stopped being the bottleneck for routine certificates and shifted into supervising the agent's draft outputs across the whole queue. Junior technicians took on more first-pass test execution because the system caught their data-entry mistakes before the certificate stage. The lab director gained, for the first time, a real-time view of the backlog by category — and started accepting more high-margin rush jobs because she could see exactly how much slack the bench had on any given day. None of these effects were in the original requirements document. They emerged from removing the paper-trail friction.


Build vs Buy: Honest Comparison

The temptation, especially in larger labs, is to buy a Laboratory Information Management System (LIMS). Here is the honest tradeoff for small certification labs:

OptionUp-frontMonthlyMultilingual certBest fit
Custom dashboard (this)~$8–18k~$30–40Native, 3 langs1–10 technicians, MENA market
Qualer LIMSlow~$200/userEN-first, AR weak10–50 users, US/EU labs
LabWare LIMS$50k+Enterprise quoteConfigurable50+ users, regulated industries
Excel + Word00Manual misery"Don't"

LIMS suites are excellent for large multi-site operations. For a single-site lab with under ten technicians, they are overkill — you pay enterprise prices for capacity you will not use, and you fight the product to make it produce a certificate that looks like the one your clients already trust. The custom dashboard wins on three axes that matter for small MENA labs: native RTL/Arabic, exact-match certificate layout, and the absence of per-user pricing as the lab grows.

The most common objection we hear is "what happens if Noqta disappears?" — a fair question for any custom build. The answer is built into the architecture. The lab owns the Postgres database, the Python service runs on a VPS the lab controls, and the templates plus migrations live in a Git repository the lab can fork. Any competent Python developer can take over maintenance in a week. There is no vendor-specific runtime, no proprietary file format, no licence server to phone home. Contrast that with a LIMS exit: extracting your historical certificates from a vendor's proprietary database when the contract ends is a project measured in months, not days. The custom path costs more on day one and substantially less on the day you would have otherwise needed to migrate.

One area where buying still beats building: if your lab is one of many sites for the same parent organisation, and head office mandates a specific LIMS for cross-site reporting, do not fight that. Build a thin adapter that pushes your local data into the corporate LIMS nightly. You get the local agility of the custom dashboard for day-to-day operations and head office gets the consolidated view they paid for. We have done this pattern twice and it works.

For deeper agent integration with internal tools, see building an MCP business assistant in Arabic — the same architectural principle (agents as draft-and-confirm, never auto-action) applies.


Production Checklist

Before you replace a paper register with this stack:

  • Cryptographic signing keys generated on hardware (YubiKey or equivalent), one per senior technician
  • Append-only Postgres role for the audit log (no UPDATE or DELETE grants)
  • Nightly encrypted backup of Postgres to off-site storage (Hetzner Storage Box, $4/month for 1 TB)
  • A dry_run flag on every agent call so the senior technician can review draft outputs before they appear in the technician UI
  • Quarterly restore drill — pull a backup, restore to a staging VPS, verify the last 100 certificates re-render byte-identically
  • A documented change-control procedure for template edits, satisfying §8.3
  • Role-based access in the dashboard UI — receptionists cannot edit test results, technicians cannot sign certificates, only senior staff can authorise backward state transitions
  • A weekly anomaly-flag review where the senior technician inspects every unresolved flag — open items should not exceed 72 hours, and that SLA should be visible on the dashboard homepage
  • Network segmentation: the dashboard host is reachable only from the lab's internal network or via VPN, never directly from the public internet — auditors increasingly ask about this and a Cloudflare Tunnel or WireGuard setup is the cheapest way to comply

Want a Lab Operations Audit?

If your lab is on paper, Excel, or a LIMS you have outgrown in the wrong direction, book a lab operations audit. We typically deliver an ISO 17025-aligned dashboard in three to four weeks for a single-site lab under ten technicians. The first conversation is a 30-minute call to map your current sample lifecycle and tell you honestly whether a custom build or an off-the-shelf LIMS is the better answer for your scale.

Read the strategic context in our pillar piece: AI M&E dashboards for MENA NGOs — the architecture pattern is the same, only the domain changes.