跳到正文
DEV Community· Aniket Hingane·· 3 小時前AI 評分35

構建 ResidueDesk-AI:回收污染分流語義模型

Building a Semantic Model for Recycling Contamination Triage

AI 導讀

作者構建個人實驗 ResidueDesk-AI,按入場回收票據將貨載分為接收、重新分選、改道或拒收,並讓禁運物品備註凌駕低殘渣率。本機 PoC 由五個步驟組成,不呼叫模型 API;十張樣本票據得出 2 宗接收、4 宗重新分選、3 宗改道及 1 宗拒收,平均殘渣率為 8.36 percent。作者指出,這只是個人實驗,尚未接入實際地磅,相關百分比亦非加工廠合約標準。

正文

Cover

How I Turned Inbound Bale Tickets into Accept, Resort, or Divert Calls

TL;DR

LD-106 looked acceptable if you only read the numbers. HDPE natural, 7,340 pounds, residue at 3.1 percent, moisture just over 1 percent. The spot-check note ruined that story: a battery pack sitting in the third gaylord. I built ResidueDesk-AI, a personal experiment, so a laptop at a fictional scale house would quarantine that load instead of waving it onto a tip floor because the residue percent was low. The full source is at https://github.com/aniket-work/ResidueDesk-AI.

The PoC is a local coordinator with five narrow steps: intake, residue scout, commodity grader, line router, and shift briefer. Tickets are JSON. Thresholds are constants I picked and wrote down as spec cards. Nothing calls a model API. python main.py prints a table and a shift brief that lists reject and divert before the clean accepts. On the ten sample tickets the brief lands at 2 accept, 4 resort, 3 divert, 1 reject, average residue 8.36 percent.

I kept the project small on purpose. Pydantic checks the ticket before any step runs. A lexical spec card is the stand-in for a policy binder. FastAPI serves the same function the CLI calls, plus a plain dashboard. From my experience, that split is what makes an agent demo debuggable. When LD-108 flips from accept to resort, the trace says moisture_cap and I can point at the mixed-paper card instead of guessing which prompt drifted.

This write-up is my experiment, not a plant procedure. I have not bolted it to a live scale, and the percents are not a mill contract. I think the useful part is the shape: a coordinator, a priority brief, and a hard override when the note mentions something that should never ride a conveyor.

Introduction

I got annoyed at a pattern in agent demos I kept cloning. A coordinator, a handful of specialists, a daily brief, all of it running on a laptop with open-source pieces. The demos were usually about calendars, inboxes, or research summaries. Those are fine toys. They are a poor fit for the question I actually wanted to poke at, which is what happens when one bad sentence in a note should overrule a clean numeric field.

Recycling contamination gave me that question without inventing a company story around it. A materials recovery facility lives on inbound loads. Each load shows up as a ticket: commodity, net weight, a residue percent from a spot check, a moisture reading, a supplier id, a timestamp, and a note written by whoever looked in the bale. The tip floor wants a call. Accept it on the primary line, send it to a resort bay, divert it to a residue bunker, or quarantine it. I am not a floor lead. I built a sample facility I named North Wharf MRF and I fed it tickets I wrote by hand.

The battery example is the one I started with. Residue at 3.1 percent is inside the accept band I later set for HDPE. If the grader ran alone, LD-106 would go to tip_floor_primary. That felt wrong the moment I typed the note. Batteries, propane, aerosols, lithium packs, and syringes are the terms I put on a prohibited list. The scout finds them. The router stops there. I put it this way because I have watched too many pipelines average a text field into a score and then lose the one token that mattered.

Moisture was the second itch. OCC at 3.2 percent residue and 8 percent moisture (LD-101) should accept. Mixed paper at 6.2 percent residue looks acceptable against the 8 percent accept cap I chose, until moisture is 19.4 percent (LD-108). Wet fiber blinds the story I was trying to model, so I worsen the call by one step. Accept becomes resort. I did not worsen divert into some fifth bucket. There is no bucket past the bunker in this PoC.

I also wanted the output to look like a shift handoff, not a chat transcript. The briefer sorts reject, then divert, then resort, then accept. A person scanning a laptop at 6 a.m. should see LD-106 before they see the pretty PET load. In my opinion that ordering is the product. The agents are just how I kept the reasons separate enough to test.

None of this is work I did for an employer. The thresholds moved while I was writing tests. The first OCC accept cap I tried was 8 percent, and the sample set got boring because too many tickets accepted. I tightened OCC to 5 percent so LD-102 at 9.4 percent would land in resort and I would have something to assert. That is an experimental choice. I say it plainly so nobody treats rules.py as a specification from a mill.

What's This Article About?

I walk through ResidueDesk-AI the way I built it. First the ticket shape and the four dispositions. Then the five steps and the spec cards they read. Then the coordinator that threads a trace through one load. Then the CLI, the chart, and the small FastAPI dashboard that calls the same run_shift function.

You will see the code for the scout, the router, and the decision function, with notes under each block about why I split them. Setup is the clone path on GitHub. The run section shows what the ten-ticket sample actually prints, including the priority brief.

I also write down the cases that surprised me once the tests existed: ceramic in a glass load with low residue, a 280 pound OCC ticket that was otherwise clean, and a film load at 28 percent residue that had no prohibited words and still had to leave the primary line. Those cases are why I do not trust a single function with a pile of if statements hiding in one module. I tried that for an hour. The trace became a paragraph and the tests got worse.

If you want the repo without the narrative, start at https://github.com/aniket-work/ResidueDesk-AI. README.md has the same images and the same counts.

Tech Stack

I kept the dependency list short. A PoC that needs three services before the first table is a PoC I will not rerun next month.

  1. Python 3.11 is the runtime. I develop on that minor version and I did not chase newer syntax.
  2. Pydantic v2 validates each ticket. A negative residue percent fails before the scout runs.
  3. The coordinator is ordinary Python, not a graph library. I wanted the call order to be a function I can read top to bottom.
  4. FastAPI exposes /api/triage/sample and serves static/dashboard.html.
  5. Rich prints the CLI table. I still judge these experiments by whether the terminal looks like a tool.
  6. Matplotlib writes the disposition chart. Four bars. I wanted a picture of the batch, not a dashboard product.
  7. pytest locks the overrides. The battery test and the moisture test are the ones I rerun after every threshold edit.

I skipped a vector database. Six spec cards do not need embeddings. I skipped a hosted model. The similar idea I was chasing, a local desk that briefs you before the day runs away, does not require a language model when the inputs are already structured and the overrides are keywords. If I add a model later, I want it rewriting the brief line into plainer words, with the disposition enum left untouched underneath.

Architecture

The picture matches the repository. main.py and the dashboard both call coordinator.py. Agents read rules.py. Analytics reads the finished report and writes images/disposition-chart.png. Sample tickets stay in sample_loads.json so the article, the tests, and the GIF describe one run.

Why Read It?

Read it if you are tired of agent write-ups where the only input is a sentence and the only output is another sentence. This one fails in obvious ways. If LD-106 accepts, the test named test_battery_note_rejects fails. That kind of failure is the feedback I want while I am still changing constants.

A few choices transferred cleanly from other personal experiments, and a few did not.

  1. Separate the override from the grade. Residue bands and prohibited notes want different tests. Mixing them into one score made LD-106 look "mostly fine" in an early printout, which is the bug I was trying to kill.
  2. Sort the brief by severity. Input order is ticket order. Handoff order is danger order. I sort with a small map: reject, divert, resort, accept, then load id.
  3. Keep one function for the decision. The CLI and the API do not each grow a copy of the rules. When I fixed underweight tickets, both clients picked it up.
  4. Store the spec as a card with an id. SPEC-OCC-01 shows up on the decision. When I change the accept cap I know which paragraph I owe an edit.

I do not think this replaces a person on a floor. I think it is a decent skeleton for a local briefing tool when the raw record is a form plus a note. The GitHub project includes the tests and the GIF so you can see the same 8.36 percent average I am quoting here.

Let's Design

Before functions, I wrote the four outcomes on paper.

  1. Accept goes to tip_floor_primary.
  2. Resort goes to qc_bay_2.
  3. Divert goes to residue_bunker.
  4. Reject goes to quarantine_pad.

Those line names are labels in a dict, not equipment I own. They keep the brief from saying only "bad" and "fine."

Sequence

The ticket

An inbound load has eight fields. load_id and supplier_id are strings. commodity is an enum: OCC, mixed paper, PET, HDPE, glass, LDPE film. net_weight_lb is an int capped at 80,000 because I wanted a ridiculous ticket to fail validation. residue_percent and moisture_percent are floats from 0 to 100. spot_check_notes is free text. received_at is a string timestamp. I did not parse it into a datetime. For this experiment the scout does not care about the hour, and I did not want timezone bugs in a PoC about residue.

Intake

Intake looks for three flags: weight under 400 pounds, blank supplier, blank timestamp. The 400 pound line is mine. LD-110 is a 280 pound OCC ticket with residue at 2.8 percent, which is in grade. I still send it to the resort bay if it would otherwise accept, because a partial bale in my sample set was usually a broken ticket, not a heroic small load. If the load is already divert or reject, I do not pile an extra disposition change on top. The flag still appears in the trace when the weight is low. The disposition change only fires on an accept.

Residue scout

The scout lowercases the note and looks for battery, propane, syringe, aerosol, and lithium. Any hit is a list of terms. I did not stem words. "batteries" would slip through, and I left that hole on purpose so I would remember how dumb keyword lists are. The sample note says "battery pack," so the test passes. A future note that says "batteries" would not, and I would rather see that miss in a test than pretend the list is complete.

Commodity grader

The grader only answers "which band." It does not pick the line. That was a deliberate split after I tangled them.

  1. Residue at or below accept_max is in_grade.
  2. Residue above that and at or below resort_max is out_of_grade.
  3. Residue above resort_max is heavy_residue.

Moisture is a boolean, and only for fiber. OCC and mixed paper compare moisture_percent to moisture_cap. PET can be wet in the ticket and the grader will say so in the trace via the moisture field, but it will not set moisture_breach. I did that because my fiber story was about soggy bales, and I did not want a 2 percent moisture PET load to wobble.

The caps I settled on, after the boring first run:

  1. OCC accepts through 5 percent residue, resorts through 12, moisture cap 18.
  2. Mixed paper accepts through 8, resorts through 18, moisture cap 16.
  3. PET and HDPE accept through 4, resort through 10, moisture cap 8, and that cap is unused for the breach flag.
  4. Glass accepts through 6, resorts through 15.
  5. LDPE film accepts through 10, resorts through 25. Film in my samples always carried loose paper, so a tight cap made every film ticket divert and I learned nothing.

Each commodity points at a card id. The card text is a short paragraph in SPEC_CARDS. The coordinator copies the first 72 characters into the trace. That truncation is a little crude. It is enough to see SPEC-OCC-01 and the start of the sentence when I am scrolling a JSON dump.

Line router

The router is the only step allowed to return a disposition. Order of checks matters, and I wrote it in that order so I would stop second-guessing it at midnight.

  1. Prohibited terms return reject immediately. Moisture and residue do not get a vote.
  2. If the note contains ceramic or stoneware, return divert. LD-107 is glass at 4.4 percent residue, which is in grade, and it still diverts because the note mentions a ceramic mug.
  3. Otherwise map the band: in grade to accept, out of grade to resort, heavy residue to divert.
  4. If fiber moisture breached the cap, worsen one step. Accept becomes resort. Resort becomes divert. Divert stays divert.
  5. If the ticket is underweight and the current call is still accept, move it to resort.

I thought about making ceramic a reject. I left it as divert because, in the little model in my head, ceramics ruin a glass furnace story but they are not the same as a battery in a gaylord. That is my opinion inside an experiment. A different card could flip it later without touching the scout.

Shift briefer

The briefer builds one line: load id, disposition, line, commodity, residue, reasons. The coordinator then sorts those lines. I do not sort inside the briefer, because a single load has no neighbors yet. Batch order belongs to run_shift.

Workflow

The flow chart is the same story with the early exits drawn out. Prohibited notes never reach the grader's band mapping. Ceramic exits before the band split. Moisture sits after accept and resort, not after reject. I drew it that way after a bug where I applied moisture worsening to a reject and the test expected quarantine_pad while the code said residue_bunker. The chart is how I check that I have not reintroduced that bug when I stare at the function.

Clients

main.py loads JSON, runs the shift, prints the table and the brief, and optionally writes a chart and a JSON file. FastAPI has two posts: sample file, or a list of tickets in the body. Both call run_shift. The HTML page is one file with a button. I did not add a build step. From my experience, a PoC dashboard that needs Node is where I stop maintaining the experiment.

Let's Get Cooking

This is the part I reread when I forget why a function exists. The code is the PoC. The paragraphs under each block are what I was protecting.

Ticket model

class Commodity(str, Enum):
    OCC = "occ"
    MIXED_PAPER = "mixed_paper"
    PET = "pet"
    HDPE = "hdpe"
    GLASS = "glass"
    LDPE_FILM = "ldpe_film"


class InboundLoad(BaseModel):
    load_id: str
    supplier_id: str
    commodity: Commodity
    net_weight_lb: int = Field(ge=0, le=80_000)
    residue_percent: float = Field(ge=0.0, le=100.0)
    moisture_percent: float = Field(ge=0.0, le=100.0)
    spot_check_notes: str = ""
    received_at: str

I started here because every later bug I have had in agent experiments was a stringly-typed field. commodity as an enum means sample_loads.json cannot say "cardboard" and silently skip a spec. I found that out by typoing mixed-paper with a hyphen and watching Pydantic reject the file. That failure is the one I want at startup. spot_check_notes defaults to empty so a ticket with no note still grades on numbers. Empty notes produce no prohibited hits, which is correct for this PoC and dangerous if you pretend the absence of a note means somebody looked carefully. I do not pretend that. The brief still shows the residue band.

Spec table

Commodity.OCC: {
    "accept_max": 5.0,
    "resort_max": 12.0,
    "moisture_cap": 18.0,
    "label": "OCC fiber",
    "card_id": "SPEC-OCC-01",
},

OCC is the card I edited most. The rest of the dict follows the same keys, which matters more than the individual percents. The grader casts accept_max with float() because I stored a mix of numbers and label strings in one dict and I did not want a surprise. A typed model for the spec would be cleaner. I left the dict because I can see every commodity on one screen. The card id is the join key into SPEC_CARDS, where the paragraph version of the rule lives. I duplicate the numbers in prose on purpose. When the prose and the float disagree, I treat that as a bug in my experiment, not as documentation drift I can ignore. The tests assert behavior from the floats. The paragraphs are for me when I open the file in six weeks.

Residue scout

def residue_scout(load: InboundLoad, trace: list[str]) -> list[str]:
    notes = load.spot_check_notes.lower()
    hits = [term for term in PROHIBITED_TERMS if term in notes]
    trace.append(
        f"scout: residue={load.residue_percent:.1f}% "
        f"moisture={load.moisture_percent:.1f}% prohibited={hits or ['none']}"
    )
    return hits

The scout returns hits and does not return a disposition. I wrote it that way after an early version returned "reject" as a string and the router had to understand two dialects. A list of terms is dumber and easier. The trace line includes residue and moisture even though the grader also prints them. When I tail a trace I want the scout line to stand alone. hits or ['none'] keeps the log from saying prohibited=[], which I misread once as a real hit on an empty string. Small thing. It saved me a confused minute, so it stayed.

Substring matching is the limitation I already mentioned. It will also false-positive if I ever write a note like "no battery found" because battery is still in the sentence. None of the sample notes do that. If I extend the PoC I would switch this to a word-boundary check and add a negation test. I am not going to claim the current list is careful. It is a tripwire for the sample set.

Router and the one-step worsen

def _worsen(disposition: Disposition) -> Disposition:
    if disposition == Disposition.ACCEPT:
        return Disposition.RESORT
    if disposition == Disposition.RESORT:
        return Disposition.DIVERT
    return disposition

This helper is the whole moisture policy. I like that it is short enough to argue with. There is no path from divert to reject here. A soaked, filthy OCC load (LD-103, 16.8 percent residue, 22.5 percent moisture) is already divert because of heavy residue. Moisture adds the reason moisture_cap and the disposition stays divert. The brief shows both reasons: heavy_residue, moisture_cap. I want both. If I only kept the final disposition I would forget that the bale was wet and dirty, not just dirty.

The router calls _worsen only after the band map, and only when moisture_breach is true. Prohibited loads return earlier, so they never worsen. That is the bugfix I drew into the flow chart.

Underweight handling sits beside it, not inside _worsen, because underweight is not a residue story. A 280 pound in-grade OCC ticket becomes resort with reasons in_grade and underweight_ticket. The grade band on the decision stays in_grade. I kept the original band so a JSON diff can show that the grader and the router disagreed in a controlled way. If I overwrote the band, I would lose that disagreement and the next debugging session would be worse.

One load, five steps

def decide_load(load: InboundLoad) -> LoadDecision:
    trace: list[str] = []
    flags = intake_agent(load, trace)
    prohibited = residue_scout(load, trace)
    band, label, card_id, moisture_breach = commodity_grader(load, trace)
    disposition, reasons = line_router(
        load, prohibited, band, moisture_breach, flags, trace
    )
    line_assignment = LINE_FOR[disposition.value]
    brief_line = shift_briefer(load, disposition, reasons, line_assignment, trace)
    card_text = SPEC_CARDS[card_id]
    trace.append(f"card: {card_text[:72]}...")
    return LoadDecision(...)

I trimmed the constructor in the snippet above so the sequence is the thing you see. The real function fills every field on LoadDecision, including the residue numbers copied off the ticket. I copy them instead of making the caller join back to the raw load. When I write --json-out, one object should explain itself.

The trace is a list of strings, not a tree. I thought about nested spans. For ten tickets, strings were enough, and pytest can startswith("intake:"). A fancy trace format would have been me playing with tooling. The order is fixed: intake, scout, grader, router, briefer, card excerpt. If a future step lands in the middle, the tests that assume a prefix on element zero still pass, and the tests that assume the last line is the card will need an edit. I will take that edit. It is honest.

run_shift maps decide_load across the list, counts dispositions, averages residue, and builds the brief. Average residue on the sample is 8.36 percent. I round to two decimals in the report so the CLI, the chart title, and this article do not drift by a float. The chart title includes that average because a bar chart of counts hides whether the batch was mostly clean with one disaster or uniformly messy. This batch is mixed, which is what I wanted from the sample authoring pass.

Let's Setup

The public repository is https://github.com/aniket-work/ResidueDesk-AI. I publish only the implementation, images/, and README.md. The article generator stays in my notes, not in that repo.

  1. Clone https://github.com/aniket-work/ResidueDesk-AI.git and change into the directory.
  2. Create a virtual environment in the project folder and activate it. I keep the venv inside the project so this experiment does not leak packages into whatever else I am running.
  3. Install from requirements.txt. You need pydantic, rich, matplotlib, fastapi, uvicorn, and pytest.
  4. Run python -m pytest tests -q. Six tests should pass. If the battery test fails, the scout or the early return in the router moved.
  5. Run python main.py --chart images/disposition-chart.png. You should see ten rows and a brief that starts with LD-106.
  6. Optional: uvicorn src.residuedesk.api:app --reload and open the local dashboard. The button posts to the sample endpoint. Same counts.

I use Python 3.11. Newer 3.12 runs I tried on this code also imported fine, but I am not promising a matrix. If matplotlib complains about a cache directory on a locked-down laptop, the CLI table still works without --chart. The chart is for the picture, not for the decision.

Environment variables: none. If a README in some other project of mine asks for an API key, that pattern does not apply here. I got tired of demos that die in setup because a key is missing for a step that could have been a lookup table.

Let's Run

Here is the sample, load by load, the way the code calls them. I am listing them in ticket order first, then I will say what the brief does with that order.

  1. LD-101 is OCC, 18,420 lb, residue 3.2, moisture 8, note about dry gaylords. Accept, tip floor. This is my control for "nothing is wrong."
  2. LD-102 is OCC, residue 9.4, moisture 11, pizza boxes in the bales. Out of grade, resort, QC bay. Moisture is under the 18 percent cap, so no worsen step.
  3. LD-103 is OCC, residue 16.8, moisture 22.5, rain-soaked. Heavy residue means divert. Moisture adds a reason and does not create a new disposition.
  4. LD-104 is PET, residue 2.1, caps mostly off. Accept. Second control, different commodity, so a bug that only special-cases OCC still has to face this row.
  5. LD-105 is PET, residue 7.6, label sleeves. Resort. The sleeve words do not match the prohibited list, and they should not. Sleeves are a grade problem, which the residue percent already represents in this experiment.
  6. LD-106 is HDPE, residue 3.1, battery pack in the note. Reject, quarantine pad. The grader still runs and still records an in-grade band, because decide_load calls the grader before the router returns. I could skip the grader on a prohibited hit. I left the call so the trace shows the temptation: the numbers were fine.
  7. LD-107 is glass, residue 4.4, ceramic mug fragments. Divert, reason non_spec_ceramic. Band would have been in grade. The router ignores that band in this branch.
  8. LD-108 is mixed paper, residue 6.2, moisture 19.4. In grade against an 8 percent accept cap, then moisture cap 16 forces resort.
  9. LD-109 is LDPE film, residue 28, loose paper and strapping. Heavy residue, divert. No prohibited terms. This is the "dirty but not dangerous" case.
  10. LD-110 is OCC, residue 2.8, only 280 lb. In grade, then underweight pushes resort.

The brief reorders that list. Reject first (LD-106), then the three diverts (LD-103, LD-107, LD-109) by load id, then resorts, then the two accepts. I sort diverts by id so the order does not depend on dict hash or file order beyond the severity key. File order still matters for the table, which follows the JSON. I want the table to match the ticket file and the brief to match the handoff. Those are different readers.

Disposition chart

The chart is those four counts. Accept 2, resort 4, divert 3, reject 1. The title on the image includes the 8.36 percent average. If you change one threshold and regenerate, the bars move and the average may not. LD-103 at 16.8 percent pulls the average up. A batch of LD-101 clones would sit near 3 percent and the bars would be a single accept column. I kept the sample uneven so the chart would look like a morning, not like a unit test fixture that forgot to be a fixture.

JSON output is the habit I actually use. python main.py --json-out shift.json writes the report, including every trace. When I moved the OCC accept cap from 8 to 5, I diffed the JSON. LD-102 changed from accept to resort. Nothing else moved. That diff is how I trust a one-line constant edit. Terminal tables lie by omission when you have scrolled.

The dashboard button hits POST /api/triage/sample. The cards on the page are the same four counts. I styled it dark so it would resemble the terminal GIF, not because dark theme is a personality. If the API and the CLI ever disagree, I broke the "one run_shift" rule and I should revert that, not add a third client to average them out.

Cases I had to pin with tests

I do not add a test for every row. I add a test when a rule exists only because I got burned.

  1. Clean OCC accepts and the line is tip_floor_primary. If I rename the line dict and forget accept, this fails.
  2. A battery note on HDPE rejects, and some reason starts with prohibited:. I check the prefix so I can add terms without rewriting the assertion.
  3. Wet mixed paper stays in_grade on the band and still resorts, with moisture_cap in the reasons. This is the test that protects the "keep the original band" decision.
  4. Ceramic glass diverts with non_spec_ceramic. Residue in that test is 4.4, inside the accept band, so a future "optimization" that returns early on in-grade would fail here. Good.
  5. Underweight clean OCC resorts. Weight is 280, residue 2.8.
  6. The sample file counts stay accept 2, resort 4, divert 3, reject 1, and the brief contains LD-106. This is the regression net for the whole batch. When I add an eleventh ticket I have to edit this test on purpose. I want that friction.

The sixth test also checks that the first trace line starts with intake:. That caught a refactor where I logged the scout first "because it is more interesting." It was more interesting and it was the wrong order. Intake flags are cheap and they should exist even when a later step throws, which is a property I want if I ever let the router raise.

Where the experiment is thin

Keyword notes are thin. A photo of a gaylord is not in this repo. I am not going to bolt on an image model and call the PoC done. The note is a stand-in for a person who already looked. If nobody looked, the scout is theater. I say that in the README limits section and I am repeating it here because it is the limitation that matters.

The caps are thin in a different way. I moved them until the sample set exercised each branch. That is a good way to test software and a bad way to claim you measured a commodity market. There is no price, no buyer, no revenue figure in the project. I left money out on purpose. Disposition is the decision I wanted to practice. A price would have turned the brief into a negotiation, and that is a different experiment.

Supplier ids look like codes (NW-FIBER-04, HARBOR-PET-02) and they do nothing except print. I thought about a supplier strike count, where two bad loads from NW-FIBER-11 escalate the third. LD-102 and LD-103 share that supplier. I did not implement the strike. A hidden counter would make the batch order change results, and then the JSON diff habit gets muddy. If I add it, it will be an explicit field on the report, not a global.

Timestamps are stored and barely used. A shift desk in a fuller experiment would group by hour and complain when tickets arrive out of order. I did not need that to prove the coordinator. Unused fields bother me less than fields that silently change a disposition.

FastAPI is thicker than the PoC needs. I kept it because I wanted a second client. The HTML is not a product surface. It does not authenticate. Do not expose it on a network and pretend the quarantine pad is protected. My laptop, my sample file, my button.

What I would add if I keep going

These are notes to myself, not a roadmap with dates.

  1. Word-boundary matching on the prohibited list, plus a test where the note says the battery was already removed.
  2. A sixth step that rewrites brief_line into a shorter spoken sentence, while disposition stays an enum the chart can count.
  3. Supplier history as an input column, not as hidden state, so two bad loads only matter if the ticket says so.
  4. A held-out JSON file of tickets I did not use to tune caps, so I can see which constants I overfit.
  5. Embedding search over a longer card file, measured against the lexical id I already store. If embeddings do not change a single disposition on the held-out file, I will delete them.

I would not add a chat box in front of the table. Every time I have done that in a personal project, I stop looking at the reasons list. The reasons list is the part that caught the moisture double-count bug. I am keeping it in the foreground.

How I checked the pictures

The cover GIF is a terminal, then the four count cards and the chart. The terminal types python main.py --chart images/disposition-chart.png, scrolls the summary, and holds on the success line with the 8.36 percent average. I care that the GIF matches the CLI, because I have published pictures that were a fictional dashboard sitting on top of different code. The counts in the GIF are 2, 4, 3, and 1. If you run the sample and see something else, trust the terminal and assume I forgot to regenerate the GIF.

The mermaid figures are the title view, the module map, the sequence, and the flow. They live under images/ in the repo. I regenerated the flow once after a label with a question mark failed to render. The version in the repo is the one without that character in the diamond nodes. The behavior in code still asks the questions. The drawing just uses plainer labels.

Title diagram

Closing Thoughts

ResidueDesk-AI is a shift brief I can run with no key and no network, on tickets I wrote, with rules I am willing to show. The battery load stays quarantined. The wet mixed-paper load does not get a free pass because residue was 6.2. The ceramic cullet does not ride along just because 4.4 percent looks tidy. Those three outcomes are the experiment.

I will keep using this shape on later personal projects: a narrow scout for the sentence that should veto the numbers, a grader that only bands, a router that owns the enum, and a brief sorted by how badly I need to see the row. The domain can change. The laptop can stay the place it runs. The repository is https://github.com/aniket-work/ResidueDesk-AI if you want the same sample morning I used while writing this.

The chart and the table will go stale the moment you edit SPECS. That is the correct failure. Update the test counts when you mean to, and leave them failing when you do not.

Disclaimer

The views and opinions expressed here are solely my own and do not represent the views, positions, or opinions of my employer or any organization I am affiliated with. The content is based on my personal experience and experimentation and may be incomplete or incorrect. Any errors or misinterpretations are unintentional, and I apologize in advance if any statements are misunderstood or misrepresented.

Tags: python, ai, opensource, recycling

來源:DEV Community · dev.to