Agentic Developer: Zero to Advanced, Part 7: The Inspection Pipeline

Classify, check the rules, decide, report. A real LangGraph graph where the code owns the order and the model owns two judgments.

agentic-ai
langgraph
python
computer-vision
Author

Hasan Goni

Published

October 17, 2026

Modified

October 18, 2026

Tip

This post is part of the Agentic Developer: Zero to Advanced series. It follows on from Part 6 – building the graph, and it is the third post of Arc 2, the agent loop.

Note

Rewritten on 2026-10-18 on the synthetic void dataset, with fresh runs and rebuilt animations. The first version used GDXray images that I may not redistribute. Several conclusions changed, and I say which ones where they appear. The headline that changed most: two plain rules beat everything with a model in it.

Part 5 ended on a sentence I want to keep: an agent loop does not add information, it only adds decisions. Part 6 then redrew that loop as a graph. Before adding more nodes, this post asks the question those two leave open: which of these decisions does a model actually need to make?

For the inspection job, the honest answer is two. Everything else, the order of the steps, the business rules, the final verdict, can be plain Python. So in this post the code owns the order, and the model is called in exactly two nodes. I build it, test every branch, and score it on the same held-out images as Part 5. Then I take the model’s guidance away to see what the structure buys on its own. The answer is smaller than I hoped, and more useful.

Each section has a short narrated animation if you would rather watch.

Outputs are collapsed by default. Each one is the real captured output of the code above it.

0.1 An agent, or a pipeline?

In Parts 5 and 6 the model chose the next step: measure another box, or submit. That is an agent. But look at what the inspection actually is. Find boxes. Measure them. Decide which are real. Apply the customer’s rules. Say pass or not. Write a note. The order never changes. Letting a model choose it adds cost and a way to fail, and buys nothing.

So I split the job in two kinds of steps:

  • Steps with a fixed answer (find boxes, measure, apply rules, pick a verdict): plain Python.
  • Steps that need judgment (is this blob a defect? how do I explain this to a person?): a model.

Hand-drawn pipeline: detect, measure, classify, rules, decide, report. Classify and report are blue model calls, the rest are green plain Python. A red arrow skips from detect to report when there are no candidates; another skips from classify to decide when the classifier output is invalid.

The pipeline. Green boxes are plain Python, blue boxes are a model call. The two red arrows are the only real branches.

Daily-life version: an airport security line. The stations are fixed: ID, bag scan, body scan. The only place a person uses judgment is when the scanner beeps. You do not hire a decision-maker to tell passengers which station comes next.

If you want the longer version of this distinction, Anthropic’s Building effective agents calls the fixed-order kind a workflow and the model-chooses-the-path kind an agent. This post builds a workflow on purpose.

0.2 The state

Same idea as Part 6, a typed whiteboard. Each node fills in its own part.

notebooks/agentic-zero-to-advanced/pipeline_graph.py
class PipelineState(TypedDict):
    image: str
    candidates: list[dict]       # from detect
    measurements: list[dict]     # from measure
    labels: list[dict]           # from classify
    classify_status: str         # "ok" | "failed" | "skipped"
    kept: list[dict]             # survivors after rules, each with a severity
    rejected: list[dict]         # everything dropped, with the reason: the audit trail
    verdict: str                 # "pass" | "review" | "escalate"
    reason: str
    report: dict | None
    trace: Annotated[list, operator.add]   # reducer: one line per node that ran
    llm_calls: int
    tokens: int

Only trace has a reducer, because every node adds a line to it. The rest are replaced. Note the rejected list: every box that did not make it has a reason attached (classifier, R1 border, R2 min area). When someone asks “why was this box dropped?”, the answer is in the state.

0.3 Nodes one and two: the Part 5 tools, called by code

These are the same two tools as Part 5, find_candidates and measure_candidate. The difference is who calls them. In Part 5 the model spent about five steps asking for them one box at a time. Here the code calls them once per image.

notebooks/agentic-zero-to-advanced/pipeline_graph.py
def detect_node(state: PipelineState) -> dict:
    cands = find_candidates(state["image"])["candidates"]
    out = {"candidates": cands, "trace": [{"node": "detect", "n": len(cands)}]}
    if not cands:
        out |= {"verdict": "pass", "reason": "the detector found no candidates"}
    return out


def measure_node(state: PipelineState) -> dict:
    ms = [measure_candidate(state["image"], c["id"]) for c in state["candidates"]]
    return {"measurements": ms, "trace": [{"node": "measure", "n": len(ms)}]}

No ground truth reaches the model, same rule as Part 5. Scoring only happens afterwards.

0.4 Node three: classify, the first model call

One batched call labels every candidate on the image: defect, false_alarm or uncertain, with a confidence. The prompt carries dev-data guidance like Part 5’s design fix (brighter boxes are real far more often: about 7 in 10 above contrast 30, about 1 in 10 below 15).

Part 4 taught validation and one retry, and Part 5 taught that a well-formed answer is not necessarily a correct one. So the check here has two layers. The JSON has to match the schema, and it has to cover exactly the candidate ids we sent. A model that silently skips a box has produced valid JSON and a wrong answer.

notebooks/agentic-zero-to-advanced/pipeline_graph.py
for _ in range(MAX_CLASSIFY_ATTEMPTS):
    resp = client.chat.completions.create(model=MODEL, messages=messages, temperature=0.2)
    ...
    try:
        parsed = Labels(**json.loads(strip_fences(raw)))
        got = [lab.id for lab in parsed.labels]
        if sorted(got) != sorted(ids):  # well-formed is not enough: it must cover exactly our ids
            raise ValueError(f"expected exactly one label for each id in {sorted(ids)}, got {sorted(got)}")
        return {"labels": [...], "classify_status": "ok", ...}
    except (json.JSONDecodeError, ValidationError, ValueError) as exc:
        error = f"{type(exc).__name__}: {exc}"
        messages += [{"role": "assistant", "content": raw},
                     {"role": "user", "content": f"That failed validation:\n{error}\nReturn ONLY the corrected JSON."}]
return {"labels": [], "classify_status": "failed", ...}   # never an empty list that looks like "all clear"

If it fails twice, the node says so (classify_status: "failed") instead of returning an empty list. An empty list would look exactly like “no defects”, which is the worst possible silent failure.

I also made the classifier swappable. The graph takes a name, and band replaces the model with a one-line rule (contrast >= 8, the best non-trivial F1 on the dev split, band_dev_sweep.py). That is not decoration, it is the control experiment further down.

0.5 Nodes four and five: rules and verdict, plain Python

Business rules are the things a QC owner would sign off on, not things a model should “feel”.

notebooks/agentic-zero-to-advanced/pipeline_graph.py
def apply_rules(measurements, labels):
    """Returns (kept, rejected). A candidate has to be labeled defect/uncertain AND pass every rule."""
    ...
        if lab["label"] == "false_alarm":
            rejected.append({"id": lab["id"], "by": "classifier"})
        elif m["edge_distance_px"] == 0:  # R1: touches the image border, the ring is clipped, not measurable
            rejected.append({"id": lab["id"], "by": "R1 border"})
        elif m["area_px"] < MIN_AREA_PX:  # R2: too small to report
            rejected.append({"id": lab["id"], "by": "R2 min area"})
        else:
            uncertain = lab["label"] == "uncertain" or lab["confidence"] < CONF_MIN
            kept.append({"id": lab["id"], "severity": severity(m["area_px"]), "uncertain": uncertain, ...})

A rule overrides the classifier. If the model calls a border-touching box a defect, R1 still drops it. The rules are the safety net under the model.

Show the verdict node (decide)
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def decide_node(state: PipelineState) -> dict:
    """Escalate whenever the pipeline itself is unsure. Never guess a pass or a fail."""
    kept = state.get("kept") or []
    if state["classify_status"] == "failed":
        verdict, reason = "escalate", "the classifier output was invalid after a retry"
    elif len(kept) > MAX_KEPT:
        verdict, reason = "escalate", f"{len(kept)} candidates survived (limit {MAX_KEPT}); the detector looks confused"
    elif any(k["uncertain"] for k in kept):
        verdict, reason = "escalate", "at least one surviving candidate is uncertain"
    elif kept:
        verdict, reason = "review", f"{len(kept)} confident defect(s) survived the rules"
    else:
        verdict, reason = "pass", "no candidate survived the classifier and the rules"
    return {"verdict": verdict, "reason": reason, "trace": [{"node": "decide", "verdict": verdict}]}

Three verdicts, and the third one is the interesting one:

Verdict Meaning What happens next
pass nothing survived the classifier and the rules image goes through
review one or more confident defects survived normal QC review
escalate the pipeline itself is not sure a human looks first (Part 9 makes this a real pause)

Daily-life version: a bank clerk with an approval limit. Small amounts, they just approve. Big or strange ones, they say “let me get my manager” instead of guessing. escalate is the manager.

0.5.1 Try the rules yourself

Here is every one of the 1,059 boxes the detector produced on the first 200 dev images and the first 200 held-out images, with its real label. Move the sliders to build your own rules and watch two things: how it does on the dev images (where I looked at the data to pick my numbers) and on the held-out images (never looked at). Try “Just R1 and R2” and then add a contrast rule on top, and see what the contrast rule costs.

images boxes kept real found of real precision recall
Every one of the 1,059 detector boxes on these 400 images is in this widget, with its real label. The dev row is where I looked at the data to pick the numbers. The held-out row is the test. Watch the two rows move apart when you tune on the dev one.

Three readings of what you just played with.

First, R1 does a lot of work for nothing: on the held-out images 187 of the detector’s 513 boxes touch the image border, and none of them is real. R2 (area under 20 px) drops 73 more, 4 of them real. Together the two rules cut the boxes from 513 to 253 and keep 77 of the 81 real ones, which is a precision of 30.4% and a recall of 19.9%.

Second, the contrast band costs real voids. The band floor of 8 that I picked on dev (it had the best F1 of any non-trivial floor) drops 93 more boxes (253 down to 160), 15 of them real voids, leaving 62. Dev F1 was almost flat across floors from 0 to 10 (0.263 to 0.269), so I should have read that as “the floor barely matters on F1”, and it matters a lot to recall.

Third, this is a small amount of signal. There are 81 real boxes among the held-out candidates, so one box moves precision by a point. Do not tune to the decimal.

The widget uses the same data as this animation, which shows where the dev bands come from:

0.6 Node six: report, the second model call

The last node writes the note a QC engineer reads. The rule I used: code owns the facts, the model only writes the prose. The verdict, the kept ids, the severities and the rejection reasons all come from the state. The model gets them as a JSON record and writes two or three sentences.

Two guards:

  • No call when there is nothing to say. On a pass the note is a template. Do not pay a model to write “nothing found”.
  • A mechanical check on the prose. The note may only cite candidate ids that exist on this image. If it cites one that does not, the model’s text is thrown away and the template is used.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def report_node(state: PipelineState) -> dict:
    summary, source, calls, tokens = template_summary(state), "template", 0, 0
    if state["verdict"] != "pass":  # nothing to explain on a pass: do not pay a model to say "nothing"
        ...
        text = (resp.choices[0].message.content or "").strip()
        if text and not prose_problems(text, state):
            summary, source = text, "llm"
        else:
            source = "template (llm prose rejected)"

That check is narrow on purpose. It catches an invented id. It does not catch a note that overclaims. More on that in the results.

0.7 Edges: only where there is a real choice

Part 6 had eight edges for a three-node loop, because the model chose the path. This graph has six nodes and only two conditional edges, because only two places have a real choice.

notebooks/agentic-zero-to-advanced/pipeline_graph.py
def after_detect(state: PipelineState) -> str:
    return "measure" if state["candidates"] else "report"


def after_classify(state: PipelineState) -> str:
    return "rules" if state["classify_status"] == "ok" else "decide"


def build_pipeline(classifier: str = "llm", **compile_kwargs):
    g = StateGraph(PipelineState)
    g.add_node("detect", detect_node)
    g.add_node("measure", measure_node)
    g.add_node("classify", CLASSIFIERS[classifier])
    g.add_node("rules", rules_node)
    g.add_node("decide", decide_node)
    g.add_node("report", report_node)
    g.add_edge(START, "detect")
    g.add_conditional_edges("detect", after_detect, ["measure", "report"])
    g.add_edge("measure", "classify")
    g.add_conditional_edges("classify", after_classify, ["rules", "decide"])
    g.add_edge("rules", "decide")
    g.add_edge("decide", "report")
    g.add_edge("report", END)
    return g.compile(**compile_kwargs)

The rule of thumb I take from this: draw an edge only when something can actually go two ways. Every extra conditional edge is a path you have to test.

0.8 Run it

The graph as data first, then one real image with its trace.

notebooks/agentic-zero-to-advanced/pipeline_inspect_demo.py
from pipeline_graph import build_pipeline, run_pipeline

graph = build_pipeline("llm")
g = graph.get_graph()
print("nodes:", list(g.nodes))
for e in g.edges:
    print(f"  {e.source:>9} -> {e.target:<9} {'(conditional)' if e.conditional else ''}")

r = run_pipeline("test_00020", graph)
for t in r["trace"]:
    print(" ", t)
Show output
nodes: ['__start__', 'detect', 'measure', 'classify', 'rules', 'decide', 'report', '__end__']
  __start__ -> detect
   classify -> decide    (conditional)
   classify -> rules     (conditional)
     decide -> report
     detect -> measure   (conditional)
     detect -> report    (conditional)
    measure -> classify
      rules -> decide
     report -> __end__

{'image': 'test_00020', 'n_candidates': 4, 'kept': 2, 'verdict': 'escalate', 'llm_calls': 2, 'tokens': 938, 'seconds': 11.3, ...}
  {'node': 'detect', 'n': 4}
  {'node': 'measure', 'n': 4}
  {'node': 'classify', 'attempts': 1}
  {'node': 'rules', 'kept': 2, 'rejected': 2}
  {'node': 'decide', 'verdict': 'escalate'}
  {'node': 'report', 'source': 'llm'}

Nine edges, as drawn. Four boxes in, two dropped by the classifier, two kept, and the verdict is escalate because the model gave one of the survivors only 0.3 confidence. Two model calls in total. Part 5’s agent spent six calls on this same image. (Here one of the two kept boxes is a real void and one a false alarm, and the pipeline handed the call to a human.)

0.8.1 One real image, start to finish

Traces are good for code. For intuition, here is one real held-out image going through all six nodes: test_00204. I picked it by a rule I wrote down before running it: the first held-out image from index 200 on (outside the 200 scored in the table below) with five to seven candidates, at least one hit, at least one miss, and at least two boxes the rules would drop. That rule picks for variety, not for how well the pipeline does. Everything on screen is read from files a real run wrote (video_frame_state.py saves the boxes, the measurements, the model’s labels, the verdict and the note), nothing is typed in by hand. The answer key stays hidden until the very end, as it does in scoring.

The detector proposed five boxes, and measure turned each into three numbers. Zero model calls so far. Then the second half:

What happened, with the real numbers:

  • classify (one call): boxes 1, 2 and 3 are false alarms (confidence 0.90, 0.95 and 0.70). Boxes 0 and 4 are uncertain, at 0.3 and 0.4.
  • rules: nothing to do. The three boxes the rules would have dropped (1, 2 and 3 are all under 20 px) were already gone because the model rejected them first.
  • decide: boxes 0 and 4 survive, both uncertain, so the verdict is escalate: a human looks first.
  • report: the second call writes the note. The facts in it (ids, severities, confidences) come from the state, not from the model. Two calls, 1,006 tokens in total.
  • the answer key: box 4 is a real void, a hit, and it is the one with the lowest measured contrast (-23.5, which reads as darker than its surroundings; I did not dig into why a box on a real void measures dark). The other real void was never boxed by the detector, so nothing downstream could ever have found it.

That is the whole results section in one picture: a pipeline that is unsure hands the case to a person, and it can only ever find what the detector proposed.

0.9 Test every branch

As in Part 6, a real run proves little about the rare paths. In the real runs below the classifier never returned invalid JSON (0 failed classifications in 870 model classifier calls across the six model runs, and only 2 retries, both in the unguided runs), so a real run proves nothing about that path. I test it with a scripted fake model: each call returns the next prepared string. The detector and the measuring tool are the real ones, on a real image (55 of the 200 held-out images have no candidates at all, so that path does run for real).

notebooks/agentic-zero-to-advanced/test_pipeline_paths.py
# 5. well-formed JSON that skips an id is NOT accepted: validating the shape is not enough
final, path, calls = run([json.dumps({"labels": json.loads(labels())["labels"][:-1]}), labels("false_alarm")])

# 6. invalid twice: the classify->decide edge skips the rules and the pipeline escalates, it does not guess
final, path, calls = run(["not json", "still not json", "A note."])

# 11. the model's prose cites an id that does not exist: it is thrown away and the template is used
final, path, calls = run([labels("false_alarm", over={5: "defect"}), "Candidate 99 looks severe."])
Show output (all 13 checks)
no candidates                      ['detect', 'report'] | model calls 0 | verdict pass
all false alarms                   verdict pass | model calls 1 (classify only) | prose from template
one confident defect               verdict review | kept [5] | model calls 2
bad JSON, retry works              status ok | attempts 2 | verdict pass
missing id is rejected             first answer covered 6/7 ids -> retried -> status ok
classifier fails twice             ['detect', 'measure', 'classify', 'decide', 'report'] | verdict escalate
uncertain survivor                 verdict escalate | at least one surviving candidate is uncertain
low confidence                     verdict escalate | at least one surviving candidate is uncertain
too many survivors                 kept 4 > limit 1 -> escalate
rules beat the classifier          rejected by rule: {0: 'R1 border', 2: 'R2 min area', 6: 'R2 min area'}
invented id in prose               prose source: template (llm prose rejected)
band classifier                    model calls 1 (report only) | verdict review
each rule and severity band        rejected {0: 'R2 min area', 1: 'R1 border'} | kept [(2, 'minor'), (3, 'major'), (4, 'critical')]

Every branch behaves, including the two the real data never takes. Writing the first version of these also caught a bug in my own test helper (integer keys passed as keyword arguments). Cheap to find here, expensive to find in production.

0.10 The result

Same protocol as Part 5’s fixes: the first 200 held-out images (387 real voids), guidance and thresholds chosen on the dev split. Model: Qwen3-Coder-30B on local vLLM, temperature 0.2. Scored at IoU >= 0.2, as before. I counted every box a human would be shown (confident and uncertain) as “kept”. The two model variants ran three times each, because Part 6 did not and I said so.

Three variants, one question each:

  • Rules only. The band classifier, no model in classify. Does the structure alone work?
  • LLM classifier, with the guidance. Does the model add anything over that rule?
  • LLM classifier, without the guidance. What happens when it has to rely on its own prior?
Show output (one line per run)
band run 1: kept 160 tp 62 fp 98 P 0.388 R 0.16 verdicts {'pass': 104, 'review': 93, 'escalate': 3} tokens 139
llm run 1: kept 146 tp 60 fp 86 P 0.411 R 0.155 verdicts {'pass': 109, 'review': 55, 'escalate': 36} tokens 552
llm run 2: kept 147 tp 60 fp 87 P 0.408 R 0.155 verdicts {'pass': 109, 'review': 54, 'escalate': 37} tokens 552
llm run 3: kept 145 tp 60 fp 85 P 0.414 R 0.155 verdicts {'pass': 109, 'review': 57, 'escalate': 34} tokens 551
llm_noguide run 1: kept 138 tp 50 fp 88 P 0.362 R 0.129 verdicts {'pass': 118, 'review': 39, 'escalate': 43} tokens 460
llm_noguide run 2: kept 141 tp 50 fp 91 P 0.355 R 0.129 verdicts {'pass': 117, 'review': 42, 'escalate': 41} tokens 461
llm_noguide run 3: kept 140 tp 50 fp 90 P 0.357 R 0.129 verdicts {'pass': 118, 'review': 41, 'escalate': 41} tokens 453
Held-out (200 images, 387 real voids) Real found (TP) Precision Recall Tokens / image Escalated Gate
Plain detector 81 15.8% 20.9% n/a n/a baseline
Part 5 agent + design fix (one run) 66 31.3% 17.1% 4,901 n/a pass
Rules R1 + R2 only, no classifier (computed, no model) 77 30.4% 19.9% 0 n/a pass
Pipeline, band classifier 62 38.8% 16.0% 139 3 fail
Pipeline + LLM classifier (3 runs) 60, 60, 60 40.8 to 41.4% 15.5% about 552 34 to 37 fail x3
Pipeline + LLM, no guidance (3 runs) 50, 50, 50 35.5 to 36.2% 12.9% about 458 41 to 43 fail x3

The “gate” is Part 5’s: precision at least the plain detector’s, recall at least 80% of the plain detector’s (here 16.7%). The 0.8 is still my choice, not a standard. The R1 + R2 row is not a pipeline run: it is the two business rules applied to the detector’s boxes (the widget’s “Just R1 and R2” button), so it costs no tokens and has no run-to-run variance.

What I read from this, in order of how much I trust it. This is where the conclusions changed from the first version of the post:

  • Two plain rules beat everything with a model in it. R1 (drop boxes touching the image border) and R2 (drop boxes under 20 px) keep 77 of the detector’s 81 real voids, at 30.4% precision, and pass the gate comfortably (recall 95% of the detector’s). Every model-in-the-loop variant here, and every agent in Part 5, finds fewer real voids than that. The first version of this post said the pipeline “passes the gate all four Part 5 agents failed”. On this data that is not what happened: the pipeline variants fail the gate, and the thing that passes is a rule.
  • The contrast rule is what fails the gate. The band floor takes precision from 30.4% to 38.8% and recall from 19.9% to 16.0%, 0.7 points under the 16.7% line. That is within the noise of one run on 387 voids, so read it as “just misses”, not as “clearly worse”. It is the price of the extra precision, and whether that trade is worth it is a decision for the line owner, not for me.
  • The model adds nothing over the rule I told it, again. With guidance, the three runs find 60 real voids against the rule’s 62, precision 41% against 39%, at four times the tokens (552 against 139) and a lot more human work: it escalates 34 to 37 of 200 images (about 18%) to a person, against 3 for the rule. In the first guided run, 21 of the 36 escalated images held a real void among their kept boxes and 15 did not.
  • Without the guidance it does not collapse this time. In the first version it did (precision 4 to 6%, because the model’s prior about contrast was backwards on that data). Here the prior is about right, so the unguided model reaches 35.5 to 36.2% precision, with 50 real voids instead of 60 and 41 to 43 escalations. The guidance buys 10 real voids and a few points of precision, not the difference between working and not working.
  • The strict view agrees. Counting only confident boxes (not the uncertain ones), the guided model is at 43 to 44% precision and the unguided one at 43 to 46%, so the gap I just described is mostly about what the model is willing to commit to.
  • Run to run, it is stable. Three guided runs: 60 real voids each time, precision within 0.6 points. Model nondeterminism at temperature 0.2 is not what moves these numbers. The small count of real boxes is.

0.10.1 The ceiling nobody mentions

Recall is stuck at 20.9% at best. Look at the plain detector’s row: 81 real voids out of 387. A pipeline that only filters the detector’s boxes can never find more than the detector proposed. Everything in Arc 2 improves the precision of the boxes, and every variant above gives up some recall to do it. None of it can fix the 306 real voids the detector never boxed in the first place. That needs different evidence, and it is why Part 17 puts a vision-language model on the actual image.

0.10.2 What the report note gets wrong

The id check did its job: across the six model runs the code never had to reject a note (prose_rejected is 0 everywhere). But it does not check wording. I scanned the 273 model-written notes of the three guided runs for a short list of confident-sounding phrases (“confirmed”, “definite”, “high confidence”, “clearly”, “obvious”, “certainly”, “severe”). It found “high confidence” in 8, and none of the other phrases. Reading those 8 against the stored confidences, I cannot call any of them a clear overclaim: the boxes they describe have confidences of 0.7 to 0.9, and in a few the note sits next to a 0.3 or 0.4 box it does not distinguish. That scan only looks for a few phrases, it is not an audit, and I did not read the other 265 notes by hand. The code-owns-the-facts rule keeps the numbers right. The tone is still up to the model.

0.11 Jargon, explained simply

Workflow. A fixed sequence of steps where code decides what runs next. Opposite of an agent, where the model decides.

Daily-life version: a recipe versus a chef improvising. Both can cook dinner. Only one needs a chef.

Pipeline. A workflow where each step’s output feeds the next, here as a LangGraph graph.

Daily-life version: an assembly line. Each station does one thing and passes the part on.

Business rule. A condition set by the people who own the process, applied the same way every time, no judgment involved.

Daily-life version: “no returns after 30 days”. The cashier does not decide, the policy does.

Override. A rule that wins over the model’s answer.

Daily-life version: the spell-checker says a word is fine, but the style guide says never use it. The style guide wins.

Escalate. Send the case to a human instead of deciding.

Daily-life version: the clerk who says “let me get my manager”.

Held-out data. Images you did not look at while writing the rules. The only fair test.

Daily-life version: a practice exam versus the real exam. If you saw the real questions, the score means nothing.

Ablation. Remove one part and measure what changes, to see what that part was worth.

Daily-life version: taking one ingredient out of a recipe to taste what it was doing.

0.12 What I did not do

  • No real severity. The severity bands (minor, major, critical) are pixel-area placeholders at 60 and 250 px. This dataset has no millimetre scale, so they cannot be tied to a real spec. A real line would take them from the customer.
  • No defect type. The dataset has one class (a void), so I classified real versus false alarm and scored it, and did not ask the model for a type it could not be checked on. Part 4 showed what a type label is worth in that situation.
  • One dataset, 200 held-out images, 81 real boxes among the detector’s candidates. Every precision figure here moves by a point if one box changes. The direction is believable. The decimals are not. No confidence intervals.
  • The thresholds came from the same dataset. The 20 px rule, the border rule and the contrast floor all came from the dev split of the same synthetic generator. The held-out images are a fair test of those images, not of a different part, a different detector or a real production line.
  • The rules-only row is not a pipeline run. It is the two rules applied to the detector’s boxes, computed from the same candidate list the widget uses.
  • The band floor is a judgment call. Dev F1 was flat from 0 to 10, so a floor of 0 (rules R1 and R2 only) was equally defensible on dev, and would have passed the gate. I chose 8 because the rule needed to do something to be a classifier, and I am telling you that was a choice.
  • The Part 5 comparison is a single earlier run at the same temperature, on the same images. I did not rerun it.
  • No cost in money, only tokens and seconds. Tokens are real. Seconds are wall time on a shared machine (3.4 s per image rules-only, about 17 s with the model classifying) and I did not control for load.
  • The narrated videos (my warm cloned voice, images from the synthetic dataset) were checked by transcript and by looking at extracted frames, not by ear. The image walk is one fresh run on one image, not one of the three runs in the table.

0.13 Reproduce this yourself

Show reproduce commands
cd quarto_blog_hasan/notebooks/agentic-zero-to-advanced
uv sync
# build the dataset first (Part 2); local vLLM on :8000 serving qwen3-coder, as in Parts 3-6. .env as in Part 4.
uv run python test_pipeline_paths.py      # every branch, scripted fake model, no GPU
uv run python pipeline_inspect_demo.py    # nodes, edges, one real image with its trace
uv run python band_dev_sweep.py           # the contrast floor, chosen on dev (no model)
uv run python video_frame_state.py        # one real run on test_00204, saved for the video (needs vLLM)
uv run python video_frame_crops.py        # zoom crops for the video, from the saved state
uv run python pipeline_graph.py           # 3 variants on 200 held-out images, about 50 min, writes out/pipeline_*.json
uv run python widget_rows.py              # the data behind the 'try the rules' widget
uv run python generate_arc2_diagrams.py   # the hand-drawn pipeline diagram

0.14 What’s next

Further reading: LangGraph’s documentation on conditional edges covers add_conditional_edges, the one call that makes the two branches in this post.

Part 8 looks hard at what we just did: some steps were deterministic tools, some were model calls. It adds a third kind in between, small “System One” models that return typed answers and calibrated probabilities instead of generated text, and compares two of them head to head on the narrow judgments in this pipeline. The probability would feed the same confidence threshold that decides review versus escalate.

Reuse

Citation

BibTeX citation:
@online{goni2026,
  author = {Goni, Hasan},
  title = {Agentic {Developer:} {Zero} to {Advanced,} {Part} 7: {The}
    {Inspection} {Pipeline}},
  date = {2026-10-17},
  langid = {en}
}
For attribution, please cite this work as:
Goni, Hasan. 2026. “Agentic Developer: Zero to Advanced, Part 7: The Inspection Pipeline.” October 17.
Free email course · 7 days

Industrial Vision Foundations

AI can write the code, but someone still has to know which method fits a real inspection line. One short lesson a day for 7 days on the classical computer vision techniques that still run production systems.

Runnable code in every lesson. No spam, unsubscribe any time.

Questions, corrections or feedback?

Found a mistake, or want a topic covered next? Leave a comment below (sign in with GitHub), or write to hasanme1412@gmail.com. I read everything.