One immutable project identity joining an agency’s costing sheet, its job list and an unindexed photo archive, so a portfolio deck becomes an export instead of an afternoon of folder-crawling.

Solo build2026Live, in daily team use

01The problem

The agency I run marketing for has been producing corporate events, exhibitions and galas for nine years, and all nine years lived in three places that did not know about each other: an accounting costing workbook, a job list in a shared drive, and over 140,000 project photographs in folders nobody had ever indexed. When a salesperson needed a portfolio deck, someone opened folders by hand, guessed which photographs belonged to which job, and rebuilt the slides. A semi-manual workbook had pushed that process to roughly twenty per cent coverage and stopped there.

The same gap arrived every week from the other direction. Social posts, website listings and search articles all needed project facts and photographs that existed and could not be found. Two marketing staff left in the middle of 2026 and their hands-on execution landed on me, which turned the problem from annoying into urgent. The failure was never a missing tool. It was a missing spine: nothing in the company knew that a project code, a photo folder, a scope of services and a client were the same thing.

02What I built

An internal platform with two halves. An intelligence layer holds companies, exhibitions and participation history; a delivery layer turns that spine into the things marketing actually ships — decks, social drafts, newsletters, articles and website listings. A Postgres spine holds the canonical entities, a local console serves the whole interface and every API route, and scheduled jobs on a dedicated office machine keep the spine fresh. It is multi-user with roles, and it is the team’s daily working surface rather than a dashboard anyone admires once.

  • Photo index built from a zero-download folder scan that parses the project code out of each path, then a vision pass writing a caption, controlled tags, a quality score and a hero flag per photograph.
  • A searchable card wall with an export basket, producing real branded decks off the company template and staying brand-correct across two brands.
  • Weekly costing automation: the accounting workbook is fetched, diffed, imported, and synced back into the job list colleagues already use.
  • A social loop with draft generation, a review queue, and a database trigger that makes human approval structurally unskippable.
  • A nightly public-event loop that discovers, dedupes, assembles a source pack, drafts trilingual articles and queues them for a human.
  • Health and self-repair: one checks producer feeds the console banner, the alert mail and an auto-fix job that repairs known failure recipes and proposes new ones.
Schematic of the material library screenA drawn, abstracted representation of the interface: a left navigation rail with four sections, a toolbar with a search and a filter control, a row of category chips beside a selection basket, and two rows of material cards below. All content is placeholder.INTELLIGENCE OVERVIEW PROJECTS LIBRARY EXPORT LIBRARY CATEGORY SELECTED PLACEHOLDER THUMBNAILS

Drawn, not captured. The rail switches working area, the chip row narrows by category, the marks at the right are the export basket, and the tiles below are material cards. Every label, card and count shown is illustrative placeholder content.

03As a product

Who buys it
Mid-size event, exhibition and experiential agencies of roughly twenty to two hundred staff who have a decade of project photography, a job-list spreadsheet, and a marketing team rebuilding the same decks by hand every week. Production houses and venue operators have the same shape of problem.
Value
It turns the archive an agency already owns into pitch material on demand. The claim is not that a model tags photographs; it is that a salesperson picks a category and gets a branded, client-correct deck in seconds, because the system knows which photographs belong to which job and which jobs are cleared to leave the building.
Positioning
A digital asset manager indexes files but knows nothing about accounting project codes or a job list, so the join stays manual. A CRM knows projects but not photographs. The spreadsheet base this started from holds the data but cannot run scheduled scrapers, vision tagging or deck generation. The difference here is the spine.
Status
Internal tool. Not commercialised, not licensed, no external customers. The architecture would license and the adjacent public-data play has been scoped separately, but as of now it serves one company.

04How it works

Stack
Python on the standard library — no web framework, no ORM. Supabase Postgres over its REST layer, a dependency-free single-file front end, python-pptx for deck rendering, Playwright for scraping and image rendering, launchd for scheduling, and a router that sends text and vision work to a model CLI with quota-aware pacing.
Shape
One spine, many consumers. Every inbound path is a diff-and-upsert that never overwrites enrichment columns, and every outbound path ends at a human approval gate before anything leaves the building.
Data flow, five levels, one approval gate A vertical flow in five levels. Costing mail, a drive scan and public event feeds enter as over 140,000 folder paths carrying no image bytes, resolve to one immutable identity per job, and are assembled with the caption and tags a vision pass wrote for each photograph; where the evidence is ambiguous a record is left unjoined. Every assembled item then meets a review level that splits it in two: an approved branch that continues to decks, social, newsletter and articles, and a rejected branch that ends there and feeds nothing. Every band is hollow and drawn at a uniform width, with no level measured, because the only quantities in this pipeline are company archive figures published as magnitudes rather than counts, so no band claims a proportion. INGEST Costing mail, scan, feeds OVER 140,000 PATHS, NO IMAGE BYTES RESOLVE One immutable id per job TAGS AND CAPTION PER PHOTOGRAPH ASSEMBLE Scope and photos joined ON DOUBT, LEFT UNJOINED REVIEW Reviewer and timestamp set APPROVED REJECTED DELIVER Decks, social, mail, SEO

Width uniform throughout — the quantities in this pipeline are company archive figures kept as magnitudes rather than counts, so every band is drawn at equal width and no proportion is claimed.

Nothing leaves the building on the system’s own authority. The database itself refuses to move an item into a sending state without a named reviewer and a timestamp, so the narrow branch at the bottom is a constraint rather than a habit.

The decision I spent longest on

Keying everything on the project code is what every spreadsheet in the company already assumed, and it is wrong: more than fifty codes turned out to be legitimately reused across genuinely different jobs. Fixing it meant an immutable per-job identity and backfilling over two thousand of them by evidence only, deliberately leaving ambiguous folders unresolved rather than defaulting them to the first match. The guard that came out of it measures whether a filename proves anything at all — the name Picture 1.jpg occurs in over 140 different folders, so it proves nothing.

05Retrieval architecture

The interesting question in this system was never how to find something. It was what the system is allowed to say it found. An event archive is evidence: a photograph either was taken at that job or it was not, and a plausible answer is worse than no answer at all.

So the whole path is built to be able to return nothing. Every layer below is a place where a guess could have been let in, and each one refuses — which is also the reason a similarity score never survived the design.

ModelAt both index and query time. A vision pass writes the searchable text once per photograph; a second model is consulted only when someone asks which past jobs to pitch, and never to run the search itself.

Corpus
Over 140,000 photographs held on company disks, the caption and tags a vision pass wrote for each one, the project rows accounting produces every week, and the scope text parsed out of the job list. The photographs are never copied into the database; only what can be read about them is.
Ingestion
Roughly thirteen scheduled jobs, weekly down to nightly. Costing workbooks arrive by mail and are diffed rather than reloaded, so an import can never overwrite a column a human filled in, and a folder scan walks the photo library without downloading a single byte. What gets refused at the door is evidence that proves nothing: a filename guard measured that one ordinary camera filename occurs in over 140 different folders, so a name alone may never attach a photograph to a job.
Index
One materialised row per project, carrying the code, the titles, the venue, the location, the scope text and every caption and tag beneath it, concatenated, lower-cased and covered by a trigram index. Behind it sits an immutable per-job identity, because the project code every spreadsheet in the company treats as a key is legitimately reused across genuinely different jobs. The first schema also carried vector columns on three tables; nothing ever populated them and a later migration dropped all three.
Query
Typed words expand through a synonym dictionary, combine with AND rather than OR, and narrow by year and category. The choice of AND is the whole argument: a tender question has a right answer and a wrong one, so widening the net to return more would only make a miss harder to see.
Selection
The mechanism is a trigram match against that one row per project, with year and category as hard filters, in a single round trip; the version before it re-scanned the whole asset table once per project and took about eight seconds for one common word. What comes back is then shaped by subtraction rather than by scoring. When the question is which past jobs to pitch, SQL first cuts the field to at most forty projects a human has flagged as externally shareable, the model picks from that shortlist and must give a reason, and any project code in its answer that was not on the list is discarded. Nothing carries a relevance score, so there is nothing for a reranker to reorder.
Grounding
Three things stand between the model and an invention. The tag vocabulary is closed at thirty-six terms on two axes, what production equipment we supplied and what kind of venue it was, and a tag outside that list is dropped in code before it reaches the database. The pitch shortlist is a whitelist, so a project the model names but was never offered simply vanishes. The last gate is not application code at all but the database itself: a trigger refuses to move anything into a sending state without a named reviewer and a review timestamp, which makes human approval on every outbound path structurally unskippable rather than a habit.

Present — this layer exists and runs.Absent by decision — the layer is not there, and the sentence beside it is the reason. Every one of the six is answered on every system in this chapter, so the rows can be read across pages.

06Numbers

MeasureFigureBasis
Photographs indexed and vision-taggedover 140,000Verified
Photo wall auto-categorised, from about 20%100%Verified
Card image latency, down from 1,071 ms229 msVerified
Concurrent users each exporting a 25-slide deck6 in 7 sVerified
Name-based category guesses the vision pass correctedabout 53%Verified
Commits, 2026-05 to 2026-08469Verified
Manual deck assembly replaced, per export30-40 minProjected
Weekly costing reconciliation removed2-3 hrsProjected

Verified — measured from the live database, production benchmarks, a recorded load test and the repository. Archive figures are given as magnitudes because the exact counts are company data. Projected — the export itself is timed, but the manual baselines behind these two rows are my estimate of the pre-automation steps — opening folders by hand, selecting photographs, rebuilding slides, reconciling three workbooks. Neither was ever timed.

07Timeline

  1. 2026-04Architecture work begins against the origin data set: a fourteen-table spreadsheet base holding roughly ten thousand rows.
  2. 2026-05First commit of the rebuilt system; the old base becomes a frozen snapshot. A read-only dashboard ships, and anonymous access is locked down the same day.
  3. 2026-06Weekly costing auto-import goes live, forward-event discovery starts running nightly, and the dashboard moves behind team authentication.
  4. 2026-07The entire marketing layer is built and shipped in a single day. Photo serving later moves off cloud storage after benchmarking, and the console becomes a real multi-user product.
  5. 2026-08An adversarial audit runs 41 agents, confirms 36 findings and ships 21 fixes. A shared-quota outage forces the database onto its own organisation; the two divergent development lines collapse back to one trunk.

08Looking back

What broke

The health alerting had never fired. A hand-assembled fake handler object meant the alert path raised an attribute error that a bare exception handler swallowed, so from launch until the audit in August not one alert email was sent — the system reported itself healthy because the thing that would have said otherwise was broken. Separately, two views rebuilt with a drop-and-create silently lost their security settings and inherited a default public grant, which is a live anonymous read leak sitting in production. And a shared storage quota blown by an unrelated sibling project took the whole platform down while the management interface stayed up, so the outage looked like an application bug for hours.

What it changed

Every one of those was the same failure: something that could not fail loudly. The rule I now work to is that a check must be able to report its own absence, and that identity is a design decision rather than a column you inherit from whoever named the folders. The other thing it changed is procedural — a memory note claiming three items were still outstanding turned out to be wrong on all three when I actually checked live state. Measure before believing, including when the thing you believe is your own notes.

Where it stands

Live and in daily production use, multi-user behind an authenticated tunnel, roughly thirteen scheduled jobs on a dedicated office machine. Internal only, no external customers, no revenue. Open work: the repository still carries the pre-rename name, ten remote branches await a merge-or-drop decision, and the weekly costing job has no catch-up if the machine misses its window.

Back to Top