#!/usr/bin/env python3 """wa_tm.py — the Tools & Materials brain. Used by the GROUP rail: wa_bridge.js -> process_capture.py, for the crew's "Tools and materials log" group. The Cloud API rail (wa_webhook.py) still carries its own older parse_tm/log_movement pair for 1:1 DMs — that duplication is KNOWN and booked, not overlooked. It is deliberately left alone here: it works, nobody asked for it, and folding it in is a change to a live rail for no gain today. When the DM rail next needs a change, it should import this instead. What it does with a message: 1. CLASSIFY it — movement / stocktake / chatter. Only a MOVEMENT earns a Movement Log row. The crew's first real posts (16-17/07) were stocktake photos ("Makita tools", "Material list for framing") — writing those as movements would put junk rows in a clean register. Classify first, write second. 2. RESOLVE names against the LIVE catalogue (93 Tools / 36 Materials / Locations) — never invent. An unresolved item stays in Notes marked CHECK for the office. 3. WRITE a validated row. See the typecast rule below. HARD RULE — never `typecast: true` on this table. Airtable's typecast does NOT validate a singleSelect; it CREATES a new choice when the value doesn't match. `Logged By` has 11 real people; the bridge reports Hus's WhatsApp pushName as "Hus" while the choice is "Huss". One typecast write invents a 12th person and nobody is told. Same for `Action`. Every select value here is matched against the live choice list or left blank — a blank field is a gap, an invented choice is a lie. The register is Hus's (rows since 26/05, structured, in use). This writes INTO someone else's live record — so it errs toward Notes and blanks, never toward a guess. """ import json, os, re, time, datetime, urllib.request, urllib.parse BASE = "appE43UvTyARe5oJs" T_MOVE = "tbl8dT8HcPzFu3HlS" T_TOOLS = "tblJLony6SeviLzU5" T_MATS = "tbl9xbpmyzrPmhW6E" T_LOCS = "tbllU4uVwxRhp0Xdg" F = { # Movement Log fields "action": "fldSZhS0ocsFF758x", "tool": "flda1LwDa05vfrHJU", "material": "fldKJq6LZMfxRqlJA", "qty": "fldbaldcuabT773Jq", "from_loc": "fld23C2ZOUU1D8miw", "to_loc": "fldD5YLBJwsnXmNME", "logged_by": "fldTwxKqIGerWZzw6", "notes": "fldrE1otZ6pBZaB7I", } # The live choice lists. Values outside these are NEVER written (see the typecast rule). ACTIONS = ["Tool moved", "Material drawn to job", "Material restocked", "Material returned", "Tool to repair", "Tool lost"] PEOPLE = ["Steve", "Liam", "Ardi", "Shane", "Donald", "Rocky", "Eddie", "Huss", "Mahdi", "Jack", "Amaan"] CACHE = "/root/.hermes/wa-bridge/tm_catalogue.json" CACHE_TTL = 6 * 3600 # The issue ledger. Two jobs: # series {brand: last_number_handed_out} — stops two blokes asking inside the same # minute both getting 43. Holes (issued, never used) are harmless; asset numbers # need to be unique, not contiguous. # pending {number: {brand, who, ts}} — numbers handed out and not yet seen on a tool. # This is what makes photo->Tools safe: a row is only created for a number the # bot ISSUED, to the brand it issued it for. A vision misread ("43" as "48") # finds no pending 48 and is refused instead of quietly inventing tool 48 in # Hus's live catalogue. ISSUED = "/root/.hermes/wa-bridge/tm_issued.json" # Number ISSUING is off unless this file exists. Off by default — see the note in # handle(). Registering tools from photos is unaffected and stays on: that fills the # register, which is the very thing that has to be true before issuing can be right. ISSUE_ON_FLAG = "/root/.hermes/wa-bridge/numbers_on" # One line per tool registered, per day. The knock-off tally reads this. TOOLDAYS = "/root/.hermes/wa-bridge/days" LOG = "/root/.hermes/logs/wa_capture.log" CONFIG_YAML = "/root/.hermes/config.yaml" def log(m): try: with open(LOG, "a") as f: f.write(datetime.datetime.now().isoformat(timespec="seconds") + " TM " + m + "\n") except Exception: pass def pat(): import yaml return yaml.safe_load(open(CONFIG_YAML))["mcp_servers"]["airtable"]["env"]["AIRTABLE_API_KEY"] def env(k): try: for line in open("/root/.hermes/.env"): if line.startswith(k + "="): return line.split("=", 1)[1].strip() except Exception: pass return "" def at_get(table, fields): """List every record of a table, returning [(id, {field: value})].""" out, offset = [], None while True: q = "&".join(["fields%5B%5D=" + urllib.parse.quote(f) for f in fields]) url = "https://api.airtable.com/v0/%s/%s?pageSize=100&%s" % (BASE, table, q) if offset: url += "&offset=" + offset r = urllib.request.Request(url, headers={"Authorization": "Bearer " + pat()}) d = json.loads(urllib.request.urlopen(r, timeout=25).read().decode()) out += [(x["id"], x.get("fields", {})) for x in d.get("records", [])] offset = d.get("offset") if not offset: return out def catalogue(force=False): """{tools|materials|locations: [[recid, name], ...]} — cached 6h on the box. The catalogue is the ground truth every name is checked against.""" try: if not force and time.time() - os.path.getmtime(CACHE) < CACHE_TTL: return json.load(open(CACHE)) except Exception: pass try: c = { "tools": [[i, " ".join(str(v) for v in (f.get("Tool Name"), f.get("Asset ID")) if v)] for i, f in at_get(T_TOOLS, ["Tool Name", "Asset ID"])], "materials": [[i, str(f.get("Material Name", ""))] for i, f in at_get(T_MATS, ["Material Name"])], "locations": [[i, str(f.get("Location Name", ""))] for i, f in at_get(T_LOCS, ["Location Name"])], } json.dump(c, open(CACHE, "w")) return c except Exception as e: log("CATALOGUE ERR %s" % e) try: return json.load(open(CACHE)) except Exception: return {"tools": [], "materials": [], "locations": []} # Same tool, different bloke, different word (Rocky, 2026-07-17): "some people call the # exact name of the tool and other people call it the nicknames". A 1/2 gun IS a rattle # gun IS a tech gun IS an impact driver. Donald's is written "Makita 1/2 gun"; the old # lockup register calls #18 a "Makita Rattle Gun" — two names for the same kind of tool, # already both in there. # # This matters because the MOVEMENT matcher looks tools up BY NAME. "Took the rattle gun # to Hornsby" finds nothing when the row says "1/2 gun": the tool is registered and still # unfindable, because the bloke moving it and the bloke who wrote it down used different # words. So aliases resolve at MATCH time, not at write time — nobody's words get # rewritten, the lookup just stops being deaf to them. # # NOT aliased on purpose: hammer drill stays its own tool. Donald's ute has BOTH an impact # drill (010) and a hammer drill (012) — they are different things and folding them # together would merge two real tools into one. # NOTE: write every alias in POST-norm form — norm() strips punctuation, so "1/2 gun" # arrives here as "1 2 gun" and an alias key of "1/2 gun" would never fire. That bug was # live for one test run and hid behind a lucky raw-substring match. ALIASES = { # TWO tools, not one (Rocky, 2026-07-17 — corrected an earlier fold that merged them): # impact DRIVER = the small one. 1/4" hex collet, drives screws. "Tech gun" is # really TEK gun, after Buildex TEK self-drilling hex-head screws. # impact WRENCH = the big one. 1/2" square drive, sockets/nuts/bolts, real torque. # "Rattle gun" properly means THIS one — it's named for the noise. # Blokes say "rattle gun" loosely for both, but folding them would let a bloke asking # for the tech gun match the 1/2" wrench. Different tool, different job. Your register # holds both: 16/17 Makita Impact Driver (small), 18 Makita Rattle Gun (big). "impact driver": ["tech gun", "tek gun", "1 4 gun", "quarter inch gun", "screw gun", "impact drill"], "impact wrench": ["rattle gun", "1 2 gun", "half inch gun", "12 gun", "impact gun"], "whipper snipper": ["viper sniper", "wiper sniper", "line trimmer", "brush cutter", "brushcutter", "weed eater", "whipper sniper"], "mitre saw": ["drop saw", "chop saw"], "cut off saw": ["quickie saw", "quicky saw", "quick cut", "stihl saw", "demo saw"], "reciprocating saw": ["recip saw", "resip saw", "sawzall"], "circular saw": ["skil saw", "skilsaw", "circ saw"], "concrete vibrator": ["vibe", "poker", "big vibrator"], "multi tool": ["multitool", "oscillating tool"], "laser": ["lazer", "laser level"], } def norm(s): return re.sub(r"[^a-z0-9 ]", " ", (s or "").lower()).strip() def canon(s): """Fold tradie nicknames onto one word so a lookup isn't deaf to them. Both sides of a match run through this — the query AND the stored name.""" t = " %s " % re.sub(r"\s+", " ", norm(s)) for proper, nicks in ALIASES.items(): for n in sorted(nicks, key=len, reverse=True): # longest first: "1/2 gun" before "gun" t = t.replace(" %s " % n, " %s " % proper) return t.strip() def match_one(name, rows): """Resolve a free-text name to ONE catalogue record. Exact-ish or nothing — an ambiguous match returns None and the raw text survives in Notes. Both sides go through canon(), so "rattle gun" finds "Makita 1/2 gun". Ambiguity still wins: with the nicknames folded there are now TWO rattle guns (the lockup's #18 and Donald's EZP018), so a bare "rattle gun" correctly resolves to neither. Refusing is the right answer — the alternative is silently picking someone's tool. """ n = canon(name) if not n or not rows: return None for rid, rname in rows: # exact if canon(rname) == n: return rid hits = [rid for rid, rname in rows if n and n in canon(rname)] if len(hits) == 1: # unambiguous substring return hits[0] toks = set(n.split()) scored = [] for rid, rname in rows: rt = set(canon(rname).split()) if toks and rt: ov = len(toks & rt) / float(len(toks)) if ov >= 0.75: scored.append((ov, rid)) if len(scored) == 1: return scored[0][1] return None def person(pushname): """Map a WhatsApp display name onto a real `Logged By` choice, or None. 'Hus' -> 'Huss' by prefix. No match = blank, never a new choice.""" n = norm(pushname).split() if not n: return None first = n[0] for p in PEOPLE: pl = p.lower() if pl == first or pl.startswith(first) or first.startswith(pl): return p return None PARSE_SYS = ( "You are a tools and materials clerk for an Australian construction company (LFCS). " "Read one WhatsApp message from the crew's tools/materials group and return JSON:\n" '{"intent": "movement"|"stocktake"|"next_number"|"done"|"help"|"chatter",\n' ' "action": one of ["Tool moved","Material drawn to job","Material restocked",' '"Material returned","Tool to repair","Tool lost"] or null,\n' ' "items": [{"name": str, "kind": "tool"|"material", "qty": number|null}],\n' ' "from_location": str|null, "to_location": str|null, "job": str|null,\n' ' "series": str|null, "count": int|null, "scope": "ute"|"floating"|null}\n\n' "intent rules — this matters more than the rest:\n" " movement = something MOVED or was taken/returned/lost/sent for repair. " "('took the Makita to Hornsby', 'dropped 20 bags back at the yard', 'gen is buggered')\n" " stocktake = a photo/list of what EXISTS, no movement. " "('Makita tools', 'Material list for framing', a photo of a shelf)\n" " next_number = ASKING a question — what asset number(s) a NEW tool should get. " "There must be an actual REQUEST: a question mark, 'what number', 'next number', " "'need N numbers', 'give me', 'what do I mark it'. Set \"count\" to " "how many he asked for (default 1; words count, 'ten' = 10). There are TWO kinds and " "\"scope\" must say which:\n" " scope=\"ute\" — a number for HIS OWN UTE's gear. ('next number for my ute', " "'need 10 numbers for the ute', 'doing my van, need five numbers'). series = null.\n" " scope=\"floating\" — a number for shared/lockup gear, identified by BRAND. " "('what number for the new makita?', 'next festool number?', 'need 10 milwaukee " "numbers'). Put the brand in \"series\".\n" " A brand named = floating. 'my ute'/'my van'/'my truck'/'the caddy' = ute. If " "neither is clear, scope = null.\n" " CRITICAL — a bare DESCRIPTION of a tool is NOT a number request. 'Makita 1/2 " "inch gun', 'Milwaukee grinder', 'Festool track saw', 'Paslode nail gun' are him " "SAYING WHAT THE TOOL IS (usually captioning a photo of it) = stocktake, NOT " "next_number. Naming a brand is not asking for anything. If he is not ASKING, it is " "not next_number. When in doubt, it is not.\n" " done = he's finished his list, OR he's agreeing that a list we read back " "to him is right. Same words either way: 'that's the lot', 'that's it', 'all done', " "'yep that's right', 'all good', 'correct', 'spot on'. Short agreement or a " "finished-signal = done.\n" " help = asking what to do / how the stocktake works. ('what am I to do " "here?', 'doing my ute, what do I do?', 'how does this work', 'what do you want us " "to do'). NOT a question about a specific tool's whereabouts.\n" " chatter = anything else (banter, questions about who has what, 'ok', 'thanks').\n" "If it did not MOVE, it is NOT a movement. When unsure, say chatter.\n" "Tradie knowledge: 4x2=90x45 timber, ply=plywood sheet, gen=generator, " "drop saw=mitre saw, yard/store=storage. Return ONLY JSON." ) def parse(msg): """Classify + extract. DeepSeek (cheap lane): T&M carries no bids/rates/margins, so it is not commercially sensitive — governance call, Rocky 2026-07-16.""" key = env("DEEPSEEK_API_KEY") if not key or not (msg or "").strip(): return None body = json.dumps({"model": "deepseek-v4-flash", "temperature": 0, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": PARSE_SYS}, {"role": "user", "content": msg}]}).encode() r = urllib.request.Request("https://api.deepseek.com/v1/chat/completions", data=body, headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) try: o = json.loads(urllib.request.urlopen(r, timeout=30).read().decode()) return json.loads(o["choices"][0]["message"]["content"]) except Exception as e: log("PARSE ERR %s" % e) return None def asset_int(v): """Plain integer Asset ID, or None. 'TEST-01' and 'EZP010' are not plain numbers.""" m = re.match(r"^\s*(\d+)\s*$", str(v or "")) return int(m.group(1)) if m else None def parse_asset(v): """Split an asset marking into (prefix, number). Rocky's two-population scheme: "43" -> (None, 43) a FLOATING tool. Lives in the lockup, belongs to nobody, numbered per brand (Makita 1-42, Milwaukee 200s...). "EZP010" -> ("EZP", 10) a UTE tool. Belongs to the ute whose rego starts EZP (Donald's Caddy, EZP44Z). The prefix IS the ownership. Prefix = first 3 chars of the rego (Rocky's call 2026-07-17). Note DK29LQ -> "DK2" and DI29MC -> "DI2" are two letters and a digit, so match on \\w not [A-Z]. """ s = str(v or "").strip().upper() m = re.match(r"^(\d+)$", s) if m: return None, int(m.group(1)) m = re.match(r"^([A-Z]{1,2}\d|[A-Z]{3})[\s-]*(\d{1,4})$", s) if m: return m.group(1), int(m.group(2)) return None, None def utes(): """Every ute -> [{prefix, rec, name, rego, driver}]. Live from Locations. THE TOOLS BELONG TO THE VEHICLE, NOT THE DRIVER (Rocky, 2026-07-17): "It could be Liam, that was my ute, it was Liam's ute, then Steve's, then mine, then I gave it to Eddie. It doesn't matter." So the REGO is the stable identity and Assigned Supervisor is just who happens to be in it this month — a fact that goes stale on every swap, and already has (Airtable still says DK29LQ is Liam's; he's in the truck and Eddie has it). Never key anything durable off the driver. """ out = [] try: rows = at_get(T_LOCS, ["Location Name", "Type", "Rego", "Assigned Supervisor"]) except Exception as e: log("UTE LOOKUP ERR %s" % e) return out for rid, f in rows: if f.get("Type") != "Ute": continue rego = (f.get("Rego") or "").strip().upper() if not rego or rego.startswith("TEST"): continue out.append({"prefix": rego[:3], "rec": rid, "rego": rego, "name": f.get("Location Name") or "", "driver": f.get("Assigned Supervisor") or ""}) return out def ute_by_prefix(pre): """The ute a marking belongs to. EZP010 -> the Caddy, whoever is driving it today.""" for u in utes(): if u["prefix"] == pre: return u return None def ute_from_text(raw, who=""): """Which ute does he mean? Named in the message first, driver only as a fallback. He usually says it — Donald: "All these tools are for caddy van". Matching on the vehicle (caddy/hiace/rego/prefix) is stable; matching on the driver is not. The driver fallback stays for "next number for my ute", but it is the WEAK path: it is only as fresh as Assigned Supervisor, which nobody updates when a ute changes hands. """ low = norm(raw) us = utes() for u in us: # rego or prefix said outright if u["rego"].lower() in low or u["prefix"].lower() in low.replace(" ", ""): return u hits = [] for u in us: # vehicle words from the location name for w in norm(u["name"]).split(): if len(w) > 3 and w not in ("hilux",) and w in low: # 3 Hiluxes = ambiguous hits.append(u) break if len(hits) == 1: return hits[0] p = person(who) # fallback: whoever the base thinks drives one if p: for u in us: if u["driver"] == p: return u return None def next_ute_number(who, count=1, raw=""): """Issue the next asset marking(s) for a UTE. Returns (ids[], err). THE TRAP THIS GUARDS: Donald's tool is engraved EZP010, and NOT ONE ute tool is in the register. So the register's max for EZP is nothing — issuing from it would hand out EZP001, which is already scratched on a tool in his van. For utes the register is BEHIND the field, the opposite of the floating tools where it's authoritative. So: if we know of no EZP tools at all, we do not guess. He registers what's marked first, and that teaches us where his series is up to. """ u = ute_from_text(raw, who) if not u: return None, "which ute? %r" % who try: count = int(count or 1) except Exception: count = 1 count = max(1, min(count, 25)) pre = u["prefix"] try: rows = at_get(T_TOOLS, ["Asset ID"]) except Exception as e: return None, "table read failed: %s" % e seen = [] for _, f in rows: p2, n = parse_asset(f.get("Asset ID")) if p2 == pre and n is not None: seen.append(n) led = load_ledger() for k in led["pending"]: p2, n = parse_asset(k) if p2 == pre and n is not None: seen.append(n) if not seen: return None, ("no %s tools registered yet — photo the ones already marked first" % pre) start = max(seen) + 1 ids = ["%s%03d" % (pre, start + i) for i in range(count)] stamp = datetime.datetime.now().isoformat(timespec="seconds") for a in ids: led["pending"][a] = {"brand": "", "who": who, "ts": stamp, "ute": u["rec"]} save_ledger(led) return ids, None def load_ledger(): try: d = json.load(open(ISSUED)) d.setdefault("series", {}) d.setdefault("pending", {}) return d except Exception: return {"series": {}, "pending": {}} def save_ledger(d): try: json.dump(d, open(ISSUED, "w"), indent=1) except Exception as e: log("LEDGER SAVE ERR %s" % e) def next_number(brand, count=1, who=""): """Issue `count` asset numbers for . Returns (numbers[], err). Derived from the LIVE table every time — never the 6h cache. A stale read hands out a number that is already painted on a tool, which is the one outcome worse than not answering. Reading 93 rows is cheap; being wrong is not. No hardcoded blocks. The scheme (Makita 1-42, Milwaukee 200s, Festool 600s, Paslode 650s, misc 700s) lives in the DATA, so a new series needs no code change. Rocky's call 2026-07-17: don't renumber, the scheme works — so read it, don't redesign it. Issued numbers go to `pending` so the photo->Tools path can verify a number was actually handed out before it writes a row. """ b = norm(brand) if not b: return None, "no brand" try: count = int(count or 1) except Exception: count = 1 if count < 1: count = 1 if count > 25: # A ute holds a few unnumbered tools, not fifty. A daft number is a misread # ("need 100 numbers") — refuse rather than burn a block out of the scheme. return None, "count %d too big — ask for 25 or fewer" % count try: rows = at_get(T_TOOLS, ["Tool Name", "Asset ID"]) except Exception as e: return None, "table read failed: %s" % e taken = set() mine = [] for _, f in rows: n = asset_int(f.get("Asset ID")) if n is None: continue taken.add(n) if b in norm(f.get("Tool Name")): mine.append(n) if not mine: # An unknown brand needs a BLOCK decision (which range does Hilti live in?) and # that is Rocky's call, not a guess by a bot. Say nothing, log it, let him decide. return None, "unknown series %r" % brand led = load_ledger() taken |= set(int(k) for k in led["pending"] if str(k).isdigit()) # don't reissue pending start = max(max(mine), led["series"].get(b, 0)) + 1 nums = [] for i in range(count): n = start + i if n in taken: # Two different faults land here, and both are Rocky's call, not a bot's: # - a SHARED block: the brand sits mid-way through the 700s junk drawer, so # its max+1 belongs to someone else (Leica 700 -> 701 is the Ramset). The # real answer is the next free slot in that drawer, but "which drawer" is a # policy question the data can't answer. # - a FULL block: a series grew into the next one (Paslode fills to 699 -> # 700 is the Leica). That needs a block decision, not a number. # Either way: refuse. Handing out a number already painted on a tool is the # one outcome worse than saying nothing. if not nums: return None, ("%s max is %d, but %d is already used — shared or full " "block, needs a human" % (brand, max(mine), n)) return None, ("%s block only has %d free before it runs into %d — needs a " "human" % (brand, len(nums), n)) nums.append(n) stamp = datetime.datetime.now().isoformat(timespec="seconds") for n in nums: led["pending"][str(n)] = {"brand": b, "who": who, "ts": stamp} led["series"][b] = nums[-1] save_ledger(led) return nums, None TOOL_FIELDS = { "name": "fldsNYCkqbZLaXA29", # Tool Name — "43 — Makita Drop Saw" "asset": "fldSNLpUNC3tzryTa", # Asset ID — "43" "category": "fldZR7eY3NtuDTwDY", # singleSelect "status": "fld8aHgMa8aTuvmZo", # singleSelect "notes": "fldM8i7F3vQLWNmzE", } TOOL_CATS = ["Power tool", "Plant", "Survey/Laser", "Access", "Other"] REGISTER_SYS = ( "You are a tools clerk for an Australian construction company. A worker photographed a " "tool he has just written an asset number on, and captioned it. Return JSON:\n" '{"brand": str|null, "tool": str|null, "number": str|null, ' '"category": one of ["Power tool","Plant","Survey/Laser","Access","Other"]|null}\n' "brand = Makita/Festool/Paslode/Milwaukee/Dewalt/Leica/Bosch/Hitachi/Ramset etc.\n" "tool = what it IS, terse, no brand, no number (e.g. 'Drop Saw', 'Hammer Drill', " "'Battery', '5.0Ah Battery', 'Charger (double)').\n" "number = the asset marking written on the tool, EXACTLY as written, as a string. " "Two forms: a plain number ('43') or a ute prefix plus number ('EZP010', 'FOD007'). " "Copy it verbatim, do not tidy it, do not strip letters.\n" "category: hand/power tools = 'Power tool'; lasers/levels = 'Survey/Laser'; " "ladders/trestles = 'Access'; compressors/generators/vibrators/welders = 'Plant'; " "else 'Other'.\n" "Null anything you cannot read. Do NOT guess a number. Return ONLY JSON." ) def read_tool_photo(caption, path): """What tool is this and what number is on it? Caption first (FREE + reliable), vision only if there's no caption. Returns dict + "src" saying where it came from. PROVEN 2026-07-17 on Donald's real photo — and it split cleanly: - vision reads TEXT well. It got "EZP010" exactly right off orange scrawl on a filthy red body in bad light. Tippex on clean plastic is easier than that. - vision infers BRAND badly. It called that tool a Makita. It's a Milwaukee — red, M18. Makita is teal. Not close. So the caller trusts vision for the NUMBER and never for the BRAND. The brand comes from the pending ledger, which knows it because we issued it. """ if (caption or "").strip(): body = json.dumps({"model": "deepseek-v4-flash", "temperature": 0, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": REGISTER_SYS}, {"role": "user", "content": caption}]}).encode() r = urllib.request.Request("https://api.deepseek.com/v1/chat/completions", data=body, headers={"Authorization": "Bearer " + env("DEEPSEEK_API_KEY"), "Content-Type": "application/json"}) try: o = json.loads(urllib.request.urlopen(r, timeout=30).read().decode()) d = json.loads(o["choices"][0]["message"]["content"]) d["src"] = "caption" return d except Exception as e: log("READ ERR (caption) %s" % e) return None if not (path and os.path.exists(path)): return None import base64 b64 = base64.b64encode(open(path, "rb").read()).decode() body = json.dumps({ "model": "openai/gpt-4o-mini", "max_tokens": 150, "response_format": {"type": "json_object"}, "messages": [{"role": "user", "content": [ {"type": "text", "text": REGISTER_SYS + "\nRead the number written/painted on " "the tool itself. If no number is legible, number = null."}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64}}]}] }).encode() r = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": "Bearer " + env("OPENROUTER_API_KEY"), "Content-Type": "application/json"}) try: o = json.loads(urllib.request.urlopen(r, timeout=45).read().decode()) d = json.loads(o["choices"][0]["message"]["content"]) d["src"] = "vision" return d except Exception as e: log("READ ERR (vision) %s" % e) return None LIST_SYS = ( "You are reading a photo of a handwritten tools list written by an Australian " "construction worker doing a stocktake of his ute. Return JSON:\n" '{"is_list": true|false, "items": [{"number": str, "brand": str|null, ' '"tool": str|null}]}\n\n' "is_list = true ONLY if this is a written LIST of several tools (multiple lines, each " "a tool with a number). A photo of an actual tool is is_list=false, items=[].\n" "Read EVERY line. For each:\n" " number = the number written on that line, exactly as written ('001', '007', '020').\n" " brand = Makita / Milwaukee / Festool / Paslode / Dewalt / Bosch / Hitachi / Honda " "/ Leica / Ramset, or null if not written.\n" " tool = what it is, without the brand and without the number ('elec saw', " "'double charger', 'impact drill', 'battery 8.0 Ah').\n" "Tradie handwriting and spelling: 'lazer'=laser, 'grinder', 'recip saw'=reciprocating " "saw, 'Milwaukee' is often scrawled short.\n" "'ELEC' MEANS CORDED — it has a plug (Rocky, 2026-07-17). NOT 'electric', which says " "nothing: every tool here is electric. Write it the way the register already does: " "'Makita Circular Saw (corded)', 'Makita Grinder (corded)' — matching 4/5/35 Makita " "Circular Saw (corded), 14 Makita Grinder (corded), 36 Makita Planer (corded).\n" "AUSTRALIAN TOOL WORDS — read the WORD, not the letters. Scrawled handwriting will " "look like something else and you must land on the tool that actually exists:\n" " 'viper sniper' / 'wiper sniper' = WHIPPER SNIPPER (line trimmer/brushcutter). This " "one is real: on the first live list, 'Honda Viper Sniper' was a Honda whipper snipper " "and it got past both a model and a human reader.\n" " 'rattle gun' = impact wrench. 'drop saw' = mitre saw. 'stihl saw' / 'quickie saw' = " "cut-off saw. 'nail gun', 'pin gun', 'framing gun' are all real. 'vibe'/'vibrator' = " "concrete vibrator.\n" "If a reading is not a tool that exists, it is wrong — pick the real tool it must be.\n" "CROSSING OUT — get this right, it cost us line 002 on the first real list:\n" " A crossed-out WORD inside a line is a CORRECTION. Keep the line, use the word he " "wrote INSTEAD. 'Milwaukee lazer 002' is line 002, a Milwaukee " "lazer — he corrected himself. Do NOT drop it.\n" " Only skip a line if the WHOLE line is struck through.\n" "NUMBERS — if you cannot read a line's number, set it to null. NEVER guess it and " "never infer it from the sequence: a made-up number lands a tool on top of a real one. " "null is safe, a guess is not.\n" "Do NOT invent lines. Do NOT tidy the numbers. Read only what is written." ) def read_list(path): """A photo of a handwritten list -> every line. Returns [] if it isn't a list. This is worth more than every other read in this module: one page of Donald's handwriting carried his whole ute — 20 tools with brand, type and number — while vision looking at the tools themselves got the brand wrong three times running. The bloke who owns the gear wrote down what it is. Trust that over a model squinting at a dirty grinder. """ if not (path and os.path.exists(path)): return [] import base64 b64 = base64.b64encode(open(path, "rb").read()).decode() body = json.dumps({ # gemini-2.5-flash, chosen by measurement against Donald's real list (2026-07-17). # Scored line-by-line against a hand read of the photo: gemini 18/18 with ZERO # differences; gpt-4o 18/18 but dropped brands on two; gpt-4o-mini missed a line # and garbled three ("iterator drill" for "hammer drill"). Handwriting is hard and # the cheap model is not up to it. A list photo happens once per ute, so paying # for the good model here costs nothing and is the difference between a register # you can trust and one you can't. "model": "google/gemini-2.5-flash", "max_tokens": 2000, "response_format": {"type": "json_object"}, "messages": [{"role": "user", "content": [ {"type": "text", "text": LIST_SYS}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + b64}}]}] }).encode() r = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions", data=body, headers={"Authorization": "Bearer " + env("OPENROUTER_API_KEY"), "Content-Type": "application/json"}) try: o = json.loads(urllib.request.urlopen(r, timeout=90).read().decode()) d = json.loads(o["choices"][0]["message"]["content"]) if not d.get("is_list"): return [] # Keep lines whose number could NOT be read (number=null). They are NOT registered # — a guessed number lands a tool on top of a real one — but they must not vanish # either. Donald's page 2 has a battery whose number is obscured in the shot; # gpt-4o-mini invented "001" for it (already his elec saw), gemini correctly refused. # Refusing is right. Silently dropping it is not: the tool is real and the office # needs telling. Not-invented and not-mentioned are both wrong. return [i for i in (d.get("items") or []) if i.get("number") or i.get("tool")] except Exception as e: log("LIST READ ERR %s" % e) return [] def register_list(items, ute, who, media=""): """Register a whole handwritten list against one ute. Returns (reply, status). EVERY row is flagged CHECK: this is a model reading biro in bad light, and a digit misread puts a tool under the wrong number. The photo path goes in the Notes so the office can settle any line against the original. Rows that already exist are skipped, not duplicated — re-sending the same list is harmless. """ try: rows = at_get(T_TOOLS, ["Asset ID"]) except Exception as e: return None, "err table read %s" % e have = set(str(f.get("Asset ID") or "").strip().upper() for _, f in rows) made, skipped, failed, unread = [], [], [], [] for it in items: _, n = parse_asset(str(it.get("number") or "")) if n is None: # Number not legible. NOT registered — a guessed number lands a tool on top of # a real one — but named in the reply so it isn't lost quietly. unread.append((it.get("brand") or "") + " " + (it.get("tool") or "")) continue asset = "%s%03d" % (ute["prefix"], n) if asset in have: skipped.append(asset) continue brand = (it.get("brand") or "").strip() tool = (it.get("tool") or "").strip() label = " ".join(x for x in (brand.title(), tool) if x).strip() note = ["From %s's handwritten list — read by photo, 17/07" % (who or "?"), "CHECK: every line read from handwriting, verify against the photo"] if media: note.append("photo on box: %s" % media) f = {TOOL_FIELDS["name"]: "%s — %s" % (asset, label) if label else asset, TOOL_FIELDS["asset"]: asset, TOOL_FIELDS["status"]: "In service", TOOL_FIELDS["notes"]: "\n".join(note), "fld37b0rqat9WOr6V": [ute["rec"]]} try: body = json.dumps({"fields": f}).encode() # NO typecast r = urllib.request.Request("https://api.airtable.com/v0/%s/%s" % (BASE, T_TOOLS), data=body, method="POST", headers={"Authorization": "Bearer " + pat(), "Content-Type": "application/json"}) urllib.request.urlopen(r, timeout=25) made.append(asset) have.add(asset) log_tool_day(who, asset, f[TOOL_FIELDS["name"]]) except Exception as e: failed.append(asset) log("LIST ROW ERR %s %s" % (asset, e)) log("LIST %s: made %d %s | already had %d | unreadable %s | failed %d" % (ute["prefix"], len(made), made[:3], len(skipped), unread or "none", len(failed))) if not made and not unread: return None, "list: nothing new (%d already registered)" % len(skipped) # QUIET while the pages land. He may post one list or three (Rocky, 2026-07-17) — a # "got 17!" after each one is noise, and the real acknowledgement is the cross-check # once he's finished. Restart his quiet timer instead. note_list(ute["prefix"], who) if unread: # The ONE exception. He's holding the paper right now and can answer in seconds; # next week the office would be guessing at it. Gaps go stale, thanks doesn't. return ("Couldn't read the number on %d (%s) — what is it?" % (len(unread), ", ".join(u.strip() for u in unread)[:60]), "list: registered %d, unreadable %d" % (len(made), len(unread))) return None, "list: registered %d (quiet, waiting for him to finish)" % len(made) def log_tool_day(who, asset, name): try: os.makedirs(TOOLDAYS, exist_ok=True) date = datetime.date.today().isoformat() with open(os.path.join(TOOLDAYS, "tools_%s.jsonl" % date), "a") as f: f.write(json.dumps({"who": who, "asset": asset, "name": name}) + "\n") except Exception as e: log("TOOLDAY ERR %s" % e) def register_tool(caption, media, who): """A photo of a freshly-numbered tool -> a Tools row. Returns (reply, status). This WRITES to Hus's live catalogue, so it refuses on anything it isn't sure of: - number must be PENDING (the bot issued it) and for the SAME brand. Kills vision misreads and blokes inventing numbers. - Asset ID must not already exist. Kills duplicates. Refusing costs a bloke one message. A junk row in a 93-tool register costs an audit. """ d = read_tool_photo(caption, media) if not d: return None, "notatool: unreadable" pre, num = parse_asset(d.get("number")) brand, tool = (d.get("brand") or "").strip(), (d.get("tool") or "").strip() if num is None: # NOT a tool-numbering photo. The caller must fall through and treat it as an # ordinary message — this group's existing traffic is materials photos captioned # "these need to go to Coogee", and assuming every photo is a numbered tool # silently ate them (found 2026-07-17, an hour after that assumption shipped). return None, "notatool: no asset marking read" asset = "%s%03d" % (pre, num) if pre else str(num) led = load_ledger() p = led["pending"].get(asset) checks = [] ute = None if pre: # --- UTE TOOL: RECORDING, not allocating. ------------------------------------- # The number is already engraved (Donald's EZP010). That marking is EVIDENCE, not # an invention, so the pending guard does NOT apply — demanding it would block the # whole ute pass, since not one ute tool has ever been registered. Instead: it must # be HIS ute's prefix, and it mustn't already exist. Uncertainty gets FLAGGED and # the photo kept, never blocked — blocking means the pass never happens. # The marking names the ute. EZP010 IS the Caddy — no lookup of who sent it, and # no check against who drives it. Rocky, 2026-07-17: the tools belong to the # vehicle; the driver changes and nobody updates the field when it does. Checking # the sender would refuse Eddie registering the Caddy's gear the day he takes it on. ute = ute_by_prefix(pre) if not ute: log("REGISTER refused: %s — no ute with prefix %s in Locations" % (asset, pre)) return None, "skip: no ute %s" % pre if d.get("src") == "vision": checks.append("number read by vision from the photo, not typed — verify") # A ute tool has NO ledger entry to supply the brand (it's a recording, not an # allocation), so there is nothing to override vision with — and vision's brand # is proven wrong: it called Donald's red Milwaukee a Makita. Left in, the first # real row read "EZP010 — Makita Impact Wrench" for a Milwaukee. So a # vision-guessed brand is DROPPED, not written. The tool type stays (shape is # far easier to call than livery) and the office fills the brand in. if brand: log("REGISTER: dropping vision's brand guess %r for %s — unreliable" % (brand, asset)) checks.append("brand not recorded (vision guess dropped)") brand = "" else: # --- FLOATING TOOL: ALLOCATION, stays strict. --------------------------------- if not p: log("REGISTER refused: %s not pending (nobody was issued it)" % asset) return None, "skip: %s not issued" % asset # THE BRAND COMES FROM THE LEDGER, NOT THE PHOTO. We issued this number for a # brand, so we know it. Vision does NOT — it called Donald's red Milwaukee a # Makita (2026-07-17). Gating on vision's brand lets the unreliable input veto # the reliable one. A CAPTION is different: he typed it, so it's checkable. if d.get("src") == "caption" and brand and p.get("brand") != norm(brand): log("REGISTER refused: %s issued for %r, caption says %r" % (asset, p.get("brand"), brand)) return None, "skip: %s issued for %s" % (asset, p.get("brand")) if d.get("src") == "vision" and brand and p.get("brand") != norm(brand): log("REGISTER: vision guessed brand %r for %s, using the ledger's %r" % (brand, asset, p.get("brand"))) brand = p.get("brand") or brand try: rows = at_get(T_TOOLS, ["Asset ID"]) except Exception as e: return None, "skip: table read failed %s" % e if any(str(f.get("Asset ID") or "").strip().upper() == asset for _, f in rows): log("REGISTER refused: asset %s already exists" % asset) return None, "skip: %s already exists" % asset label = " ".join(x for x in (brand.title() if brand else "", tool) if x).strip() name = "%s — %s" % (asset, label) if label else asset fields = {TOOL_FIELDS["name"]: name, TOOL_FIELDS["asset"]: asset, TOOL_FIELDS["status"]: "In service"} if ute: # The ute pass fills in the location for free — 90 of 93 tools have none. He's # photographing his own gear, so it's in his ute by definition. fields["fld37b0rqat9WOr6V"] = [ute["rec"]] cat = d.get("category") if cat in TOOL_CATS: fields[TOOL_FIELDS["category"]] = cat # validated, never typecast note = ["Registered from WhatsApp photo — %s" % (who or "?")] if caption: note.append("caption: %s" % caption) if not brand: checks.append("brand not read") if checks: note.append("CHECK: " + "; ".join(checks)) if media: note.append("photo on box: %s" % media) fields[TOOL_FIELDS["notes"]] = "\n".join(note) try: body = json.dumps({"fields": fields}).encode() # NO typecast r = urllib.request.Request("https://api.airtable.com/v0/%s/%s" % (BASE, T_TOOLS), data=body, method="POST", headers={"Authorization": "Bearer " + pat(), "Content-Type": "application/json"}) rec = json.loads(urllib.request.urlopen(r, timeout=25).read().decode()) except Exception as e: log("REGISTER ERR %s" % e) return None, "err %s" % e if asset in led["pending"]: del led["pending"][asset] save_ledger(led) log_tool_day(who, asset, name) log("REGISTERED %s -> %s (%s)%s" % (name, rec.get("id"), who, " CHECK" if checks else "")) return first_ack(who, asset), "registered %s" % rec.get("id") # The cross-check state, one entry per ute prefix. # {"EZP": {"who":"Donal", "last": , "posted": }} # Rocky's flow (2026-07-17): he may post one list or three, whenever. Opsman stays quiet # while they land, then when he's FINISHED it posts back everything it has for that ute # and asks him to check it. He confirms. THEN it thanks him. The thanks belongs to the # finished job, not to each photo — thanking per photo is the robot stamp we already # rejected this morning. CROSSCHECK = "/root/.hermes/wa-bridge/crosscheck.json" QUIET_SECS = 180 # no new list photo for this long = he's done # How long a posted cross-check stays answerable. 12h, not 30min: Donald got the list # while DRIVING and said he'd answer at the next job (2026-07-17). A half-hour window is # a desk assumption about a bloke in a van — he'd have replied "yeah that's right" to # nothing and never been thanked. Covers a working day and does not run into tomorrow, # when "yeah" means something else entirely. CONFIRM_WINDOW = 12 * 3600 def load_cc(): try: return json.load(open(CROSSCHECK)) except Exception: return {} def save_cc(d): try: json.dump(d, open(CROSSCHECK, "w"), indent=1) except Exception as e: log("CC SAVE ERR %s" % e) def note_list(prefix, who): """A list photo just landed for this ute. Restart his quiet timer.""" d = load_cc() d[prefix] = {"who": who, "last": datetime.datetime.now().timestamp(), "posted": 0} save_cc(d) def build_crosscheck(prefix): """Everything we hold for one ute, in his own numbering, for him to check. Uses the BARE numbers he wrote on his paper (001, not EZP001) — he's cross-checking against that page, so it has to read line-for-line the same. """ u = ute_by_prefix(prefix) if not u: return None try: rows = at_get(T_TOOLS, ["Asset ID", "Tool Name"]) except Exception as e: log("CC READ ERR %s" % e) return None mine = [] for _, f in rows: p, n = parse_asset(f.get("Asset ID")) if p != prefix or n is None: continue name = re.sub(r"^\S+\s*—\s*", "", f.get("Tool Name") or "") mine.append((n, name)) if not mine: return None mine.sort() lines = "\n".join("%03d %s" % (n, name) for n, name in mine) return ("Nice one, that's the lot in the system. %d tools for %s.\n\n" "When you get a sec can you check these please, and say if any are wrong:\n\n" "%s\n\nAnything missing or read wrong, just say." % (len(mine), u["rego"], lines)) def crosscheck_due(): """Utes whose bloke has gone quiet -> [(prefix, who)]. The cron asks this.""" now = datetime.datetime.now().timestamp() out = [] for pre, e in load_cc().items(): if e.get("posted"): continue if now - e.get("last", 0) >= QUIET_SECS: out.append((pre, e.get("who", ""))) return out def mark_posted(prefix): d = load_cc() if prefix in d: d[prefix]["posted"] = datetime.datetime.now().timestamp() save_cc(d) def awaiting_confirm(who): """Has this bloke got a cross-check sitting unanswered? -> prefix or None. Only inside the window, and only for the bloke it was posted to. Outside that, "yeah" is just a bloke saying yeah — thanking him for it would be the bot talking nonsense at the crew. """ now = datetime.datetime.now().timestamp() for pre, e in load_cc().items(): if not e.get("posted"): continue if now - e["posted"] > CONFIRM_WINDOW: continue if person(e.get("who", "")) == person(who): return pre return None def clear_cc(prefix): d = load_cc() d.pop(prefix, None) save_cc(d) ACK_FILE = "/root/.hermes/wa-bridge/ack.txt" ACK_SEEN = "/root/.hermes/wa-bridge/ack_seen.json" HELP_FILE = "/root/.hermes/wa-bridge/help.txt" # The bridge hard-caps a send at 200 chars so it can't waffle. Fit the help inside that # rather than raise the cap — the cap is doing real work, and a crew message nobody reads # is worse than no message. Rocky edits help.txt; the model only decides WHETHER he asked, # never what the answer says. That is the whole reason this is safe: no generated words. HELP_DEFAULT = ('Write every tool number you find in your ute on paper. Photo the list, ' 'caption "my ute", post here. Tool with no number? Ask "next makita ' 'number?" and I\'ll give you one. Tippex it on, photo it.') def help_text(): try: t = open(HELP_FILE).read().strip() if t: return t except Exception: pass return HELP_DEFAULT THANKS_FILE = "/root/.hermes/wa-bridge/thanks.txt" def thanks_text(who): """The thank-you, once, when the job is actually finished and confirmed. Rocky edits thanks.txt; {who} swaps in his name.""" try: t = open(THANKS_FILE).read().strip() if t: return t.replace("{who}", who or "mate") except Exception: pass return "Beauty. Thanks %s, that's your ute done." % (who or "mate") def first_ack(who, num): """ONE short thanks, the FIRST time a bloke registers a tool each day. Not every photo. Rocky's ask 2026-07-17: don't be afraid to say good on ya. But praise on every photo is a robot stamp — by tool fourteen it's spam and they'll take the piss or mute the group. Once a day per bloke, then the knock-off tally does the rest. Edit ack.txt to set the voice; {who} is swapped for his name. """ if not who: return None date = datetime.date.today().isoformat() seen = {} try: seen = json.load(open(ACK_SEEN)) except Exception: pass if seen.get(who) == date: return None seen[who] = date try: json.dump(seen, open(ACK_SEEN, "w")) except Exception: pass try: t = open(ACK_FILE).read().strip() except Exception: t = "Good on ya {who}, got it." return t.replace("{who}", who).replace("{num}", str(num)) def post_row(fields): body = json.dumps({"fields": fields}).encode() # NO typecast — see the hard rule above r = urllib.request.Request("https://api.airtable.com/v0/%s/%s" % (BASE, T_MOVE), data=body, method="POST", headers={"Authorization": "Bearer " + pat(), "Content-Type": "application/json"}) return json.loads(urllib.request.urlopen(r, timeout=25).read().decode()) def build_row(p, raw, who, media=""): """Turn a parsed movement into validated Movement Log fields. Anything that will not resolve goes to Notes as CHECK — never a guessed link.""" cat = catalogue() fields, unresolved = {}, [] action = p.get("action") if p.get("action") in ACTIONS else None if action: fields[F["action"]] = action else: unresolved.append("action=%r" % p.get("action")) tools, mats, qty = [], [], None for it in (p.get("items") or []): nm, kind = (it.get("name") or "").strip(), it.get("kind") if not nm: continue rid = match_one(nm, cat["tools"]) if kind == "tool" else match_one(nm, cat["materials"]) if rid: (tools if kind == "tool" else mats).append(rid) if it.get("qty") is not None and qty is None: qty = it["qty"] else: unresolved.append("%s %r%s" % (kind or "item", nm, " x%s" % it["qty"] if it.get("qty") is not None else "")) if tools: fields[F["tool"]] = tools if mats: fields[F["material"]] = mats if qty is not None: fields[F["qty"]] = qty for key, fk in (("from_location", "from_loc"), ("to_location", "to_loc")): v = (p.get(key) or "").strip() if not v: continue rid = match_one(v, cat["locations"]) if rid: fields[F[fk]] = [rid] else: unresolved.append("%s %r" % (key, v)) pers = person(who) if pers: fields[F["logged_by"]] = pers else: unresolved.append("logged_by=%r" % who) note = ["WhatsApp T&M capture — from %s" % (who or "?"), "raw: %s" % raw] if p.get("job"): note.append("job said: %s" % p["job"]) # To Job link deliberately not guessed if unresolved: note.append("CHECK (unresolved): " + "; ".join(unresolved)) if media: note.append("photo on box: %s" % media) fields[F["notes"]] = "\n".join(note) return fields, unresolved def handle(raw, who, media="", dry=False): """Entry point. Returns {"status": str, "reply": str|None}. `reply` is the ONLY way this module can make the bot speak, and it is only ever set for a next_number answer — a direct reply to a direct question. Everything else stays silent. The bot talking when nobody asked is what made yesterday's questions spam. """ if not (raw or "").strip(): return {"status": "skip: empty", "reply": None} p = parse(raw) if not p: return {"status": "skip: no parse", "reply": None} intent = p.get("intent") if intent == "next_number": # OFF by default since 2026-07-17. Rocky's call, and the evidence backs it: it # issued Donald THREE numbers during his stocktake and all three were wrong — # "43" for a tool that is EZP018 on his own list, then "203"/"204" for the # batteries that are EZP019/EZP020. Meanwhile he wrote out 001-020 himself and # never needed it. # # It is not a bug I can fix by fixing code. The bot answers from Airtable, and # Airtable knows 1 of Donald's 20 tools — the REGISTER IS BEHIND THE FIELD, so # every answer is arithmetic over a list that is mostly missing, delivered with # confidence to a crew who can see it's wrong. Silence beats that. # # Turn it back on by creating the flag file — but only once the register actually # reflects the utes, which is the whole point of the stocktake. The real use is # months out: "what's next?" when nobody remembers. if not os.path.exists(ISSUE_ON_FLAG): log("NEXT-NUM asked but issuing is OFF (register is behind the field) — %r" % raw[:60]) return {"status": "skip: number issuing off", "reply": None} count = p.get("count") or 1 if p.get("scope") == "ute" or (not p.get("series") and p.get("scope") != "floating"): # HIS ute's gear — the prefix scheme. If we know of none of his tools yet we # cannot know where his series is up to (for utes the register is BEHIND the # field), so say so rather than hand out a number already on a tool. ids, err = next_ute_number(who, count, raw) if not ids: log("NEXT-NUM (ute) no answer (%s) — %r" % (err, raw[:80])) if err and "registered yet" in err: return {"status": "skip: ute (%s)" % err, "reply": "Photo the ones already marked first, then I'll know " "where you're up to."} if err and err.startswith("which ute"): return {"status": "skip: ute (%s)" % err, "reply": "Which ute? Give us the rego."} return {"status": "skip: ute (%s)" % err, "reply": None} reply = ("Your ute: next is %s" % ids[0] if len(ids) == 1 else "Your ute: %s-%s are yours" % (ids[0], ids[-1])) reply += ". Check for an old number first." log("NEXT-NUM (ute) %s -> %s — %r" % (who, ids, raw[:60])) return {"status": "next_number ute=%s" % ids, "reply": reply} nums, err = next_number(p.get("series"), count, who) if not nums: # Silence beats a guess: unknown brand or a full block is a human decision. log("NEXT-NUM no answer (%s) — %r" % (err, raw[:80])) return {"status": "skip: next_number (%s)" % err, "reply": None} series = (p.get("series") or "").strip().title() # The check-for-an-old-number nudge, at the only moment it can work: he has asked # for a number and has not yet marked the tool. Rocky's catch 2026-07-17 — a # faded/engraved number that gets missed means the tool is renumbered, the # register grows a phantom, and the OLD number never moves again, so months later # it reads as stolen. Worst on the 9 identical Festool batteries: if the number is # gone, nothing about the object can tell you which one it is. Code cannot catch # this (the number IS issued, and it ISN'T taken — both guards pass), so the only # defence is making him look before he marks. if len(nums) == 1: reply = "%s: next is %d. Check it's got no old number on it first." % (series, nums[0]) else: reply = ("%s %d-%d are yours. Check each one for an old number before you mark it." % (series, nums[0], nums[-1])) log("NEXT-NUM %s -> %s — %r" % (series, nums, raw[:60])) return {"status": "next_number %s=%s" % (series, nums), "reply": reply} if intent == "done": # Same words, two meanings, and the STATE decides which — not the classifier. # Before the cross-check "that's it" means "I've finished my list". After it, the # same words mean "yes that's right". Asking a model to tell those apart from the # text alone would be guessing; the state knows for certain. pre = awaiting_confirm(who) if pre: clear_cc(pre) log("CONFIRMED %s by %s — job done" % (pre, who)) return {"status": "confirmed %s" % pre, "reply": thanks_text(who)} d = load_cc() for p2, e in d.items(): if person(e.get("who", "")) == person(who) and not e.get("posted"): txt = build_crosscheck(p2) if txt: mark_posted(p2) log("CROSSCHECK posted for %s (he said he's done) — %s" % (p2, who)) return {"status": "crosscheck %s" % p2, "reply": txt, "long": True} log("DONE said but nothing pending for %r — %r" % (who, raw[:50])) return {"status": "skip: done, nothing pending", "reply": None} if intent == "help": log("HELP asked — %r" % raw[:60]) return {"status": "help", "reply": help_text()} if intent != "movement": log("CLASSIFY %s (no row) — %r" % (intent, raw[:80])) return {"status": "skip: %s" % intent, "reply": None} fields, unresolved = build_row(p, raw, who, media) # A movement of NOTHING is not a movement. Liam's real first message was "These need # to go to Coogee" (2026-07-17) — "These" points at something the bot cannot see, so # it wrote Material-drawn-to-job -> Coogee Lockup with no material and no qty. The # location and the person were both right; the row was still hollow, and a hollow row # sits in the register forever saying that something, somewhere, went to Coogee. # Require at least ONE item — resolved to the catalogue, or named and unresolved. has_item = (fields.get(F["tool"]) or fields.get(F["material"]) or any(not u.startswith(("action=", "logged_by=", "from_location", "to_location")) for u in unresolved)) if not has_item: log("MOVEMENT skipped, no items — %r" % raw[:80]) return {"status": "skip: movement with no items", "reply": None} if dry: return {"status": "DRY " + json.dumps(fields)[:400], "reply": None} try: rec = post_row(fields) log("MOVEMENT row %s%s — %r" % (rec.get("id"), " CHECK(%d)" % len(unresolved) if unresolved else "", raw[:60])) return {"status": "row %s" % rec.get("id"), "reply": None} except Exception as e: log("MOVEMENT ERR %s — %r" % (e, raw[:80])) return {"status": "err %s" % e, "reply": None}