For my biggest AIDA (AI-drevne applikationer) project, a classmate and I built a digital system for the Christmas market at Engestofte Gods. The real client is Lise, the organizer, who until now ran the whole thing by hand: stadeholder (vendor) applications arrived by e-mail, she categorized them in her head, and she remembered who came last year — or didn’t. There was no memory in the system, because there was no system.
This is the longer write-up of the project from start to finish. It was a two-person build, so I’ll be clear about which half is mine.
The Two Halves#
My classmate owned the public side: the Christmas-market website, the application form, the e-mail pipeline, and the public vendor list. My half was the admin dashboard — the tool Lise actually uses to run the market — and all of the AI features on top of it.
We shared one repository and one database schema, but the work split cleanly. His code is the intake; mine is everything that happens after an application lands. The one rule we agreed on was that I would never change the shape of his public API responses, so my work could grow without ever breaking his.
Planning Before Building#
Like my other projects, I built this with Claude Code — but the interesting part this time was how much happened before any code existed.
I didn’t start by prompting “build me a dashboard.” I started by brainstorming the concept,
then writing a detailed specification — a PLAN.md — describing the data model, the features,
the phasing, and the constraints. Claude Code produced a first version of that plan, and I
read it critically and pushed back. I changed the tech stack (it had suggested something I
didn’t want; I moved it to PostgreSQL accessed through the pg driver, with Docker Compose so
both of us would run an identical database). I reshaped the data model and the build order.
Then I fed the revised plan back through another Claude conversation, refined it again, and did
this for two or three rounds until the spec was genuinely tight.
Only then did I let Claude Code execute it. And it ran — the whole foundation came up on the first try, with no errors. I’ve stopped being surprised by this: a vague prompt produces broken code, and a precise spec produces working code. The fact that it compiled first time wasn’t luck, it was a reflection of how much work went into the plan. The parts I still did by hand were the ones a spec can’t decide for you — the UI and UX choices, where things go on screen, how an interaction should feel.
I think of this as spec-driven development: the intellectual work is the specification and the review loop, and the agent handles the translation into syntax.
The Core Idea: Year-over-Year Memory#
The single most important decision in the whole project is in the data model, so it’s worth explaining.
A vendor is a permanent thing — a company that exists across years. Their participation in a specific market-year is a separate record. So the schema splits identity from event:
vendorholds the durable stuff — contact details, and the notes and photos that accumulate.participationlinks a vendor to oneevent(the 2025 market, the 2026 market), and holds the per-year facts: status, the submitted application, the category, the stand placement.- A
UNIQUE(vendor_id, event_id)constraint means one participation per vendor per year.
The “memory” that is the entire point of the product isn’t a feature I bolted on — it falls out of this schema for free. Because notes and photos attach to the durable vendor, they pile up year after year. If I’d stored one flat row per application instead, the same company applying twice would be two unrelated rows, and the memory would be impossible.
Part 1 — Building the Dashboard#
With the plan in place, the first half of the project was the dashboard itself, built in phases that each ended in something demoable.
Storage and setup. PostgreSQL via the pg driver, running locally in Docker. Schema lives
in numbered SQL migrations that run once and are never edited; initial data lives in separate,
idempotent seed scripts that are safe to re-run on every start. Keeping schema changes and
data loading as two different mechanisms is what stops two developers’ databases from quietly
drifting apart.
Vendor portfolio cards. One card per vendor showing contact info, category, and a timeline of notes and photos grouped by year — the visible form of the memory idea.
The floor plan. This was the most fun part. Vendors get dragged onto labeled stands on an
SVG of the venue, using @dnd-kit. A few design choices I’m proud of:
- Placement is spot-based, not free coordinates. A participation points at a spot, and the spot owns its position and zone. Dropping a token on an occupied spot swaps the two vendors in a single database transaction, so a half-finished swap can never leave two vendors fighting over one stand.
- The database enforces “one vendor per stand per year” with a partial unique index, so even a bug in my code can’t double-book a stand. The constraint lives in the database, not in app logic.
- Spot positions are stored as normalized coordinates (fractions from 0 to 1), so the canvas can resize and the real venue drawing can be swapped in later without any stored position breaking. Dragging a spot itself moves every vendor on it automatically, because vendors reference the spot, not a pixel.
Voice notes. Lise can record a memo in the browser; it’s sent to OpenAI Whisper, transcribed, and attached to a vendor. There’s a 10-minute cap, checked before the transcription call so an oversized recording never triggers a paid request that would just fail anyway.
Deduplication. When an application comes in, the system has to decide whether it’s a known vendor or a new one — get that wrong and years of notes detach from the right company. My matcher is deterministic and deliberately biased toward creating a separate vendor when it’s unsure. The reasoning is about which mistake is cheaper to fix: a wrong merge fuses two companies' histories and is painful to unpick, while a wrong split is harmless and has a one-click merge tool as the remedy. So I chose the reversible error.
Part 2 — Real Data and Five AI Techniques#
The second half had two jobs: replace the placeholder venue with the real one, and turn the project into a proper showcase of AI techniques.
Real venue. The organizer handed over the four real 2025 floor plans as PDFs. I converted each to a background image, replaced the placeholder zones with the four real buildings, seeded the real 2025 stand placements as history, and carried the returning vendors forward into the 2026 event so they were actually placeable. That last step also gave the AI a real “where did this vendor stand last year” signal to work with.
The AI layer. By the end, the finished system demonstrates five distinct AI techniques, each built around the same principle — the AI proposes, the system and the human dispose — and each with a deterministic fallback so it still works with no API key:
- Structured classification. Incoming applications are categorized into a fixed set of categories with tags and a short reasoning, using the OpenAI Responses API with a strict JSON schema. The category is only a suggestion; Lise can edit it.
- Speech-to-text. Whisper transcribes the voice notes described above — the one technique with no sensible rule-based fallback.
- Summarization. A portfolio digest condenses a vendor’s whole history — every note, every participation — into a short summary plus a few highlights, on demand and cached with a timestamp. Without a key, it falls back to a count-based factual sentence.
- Constraint reasoning with guardrails. A “suggest placements” feature proposes stands for unplaced vendors under soft rules: food vendors in food-safe zones, large stands against a wall, prefer last year’s spot. This is the one I’d point to first.
- Structured extraction with entity grounding. After a voice note is transcribed, the system extracts which vendor it’s about and any to-do items, matching the vendor against the real candidate list.
Guardrails — Why I Don’t Trust the Model#
The placement feature is where the project’s main idea about safe AI shows up clearly.
When the model suggests placements, nothing is written to the database in that step. Every assignment it returns is validated server-side first: the spot has to exist, be empty, and not be double-booked within the same batch. Anything that fails is dropped and reported as “unassignable.” Only when Lise reviews the suggestions and clicks apply does anything get saved, through the same normal placement endpoint a manual drag would use.
The same pattern protects the voice extraction: the vendor ID the model returns is checked against the real candidate list, and discarded if it isn’t there. The model can point at a real vendor; it can never invent one.
I started from the assumption that the model will hallucinate — a stand that’s already taken, a vendor that doesn’t exist — and built the system so that when it does, the bad output is caught before it can corrupt anything. Treating LLM output as untrusted input, the same way you’d treat data from a form, is the thing that makes an AI feature safe to connect to a database.
What I Learned#
Three things stuck with me.
With a coding agent, the leverage moves to the specification. The quality of the output tracked the quality of the plan almost exactly. My time was best spent thinking hard about the data model and the constraints, not typing.
Guardrails matter more than the model. The interesting engineering in an AI app isn’t the API call — it’s everything you build around it to stay safe when the model is wrong.
A deterministic fallback forces honesty. Building a simple non-AI version of each feature made it obvious where the AI genuinely earned its place and where it barely beat a plain rule — which is a useful question to be able to answer about your own product.
The project is on GitHub.