← Spirit Spy

Download the Markdown ↓

Spirit Spy: Vision & Build PRD

A build-it-yourself brief for a self-hosted, no-subscription spirits-collection catalog with photo-label intake.

Scope. This document is a build specification, not a supported software distribution. It provides the product model, requirements, sequencing, and implementation lessons needed to create your own version. Your resulting application, technology choices, security posture, deployment, and maintenance remain yours.

Version: 1.3. Covers the as-built system through the cocktails layer: photo→label intake, the expression-vs-bottle model, web enrichment that cites its sources (and fills blanks, specs included), the public showcase, local CPU background-knockout, a private wishlist, and cocktails (per-bottle real-recipe enrichment in labeled tiers, a mixologist browse across the whole shelf, and fenced off-menu AI invention). Built and running. The enrichment sections reflect real-world hardening (multi-page crawl, the "known context" trap, ambiguous-name search, a manual deep-dive escape hatch, and the recipe-page tuning that specs enrichment does not need); the data-model section reflects the modeling lessons that surfaced in use: size lives on the bottle, capture-loose-then-merge, and "same name is not the same product." Audience: someone technical-ish who wants to roll their own hobby-collection tracker, own their own data, and feed this brief to an AI coding agent on whatever stack they prefer.

How to use this document

This is a product requirements + vision brief, not a codebase. Hand it to a capable AI coding agent (or read it and build by hand). It describes what the catalog does and how the pieces fit, plus the hard-won facts about label OCR and web enrichment that will save you days. It deliberately avoids prescribing a language, framework, database, or model: those are yours to choose. Where a decision matters, the brief explains the reasoning so you can adapt it to your collection and your hardware rather than copy ours.

There is no repository and no shared code by design. The goal is for you to build something that fits your shelf, your taste in detail, and your setup, grounded in your data, rather than inherit assumptions baked into someone else's build.

Why a PRD instead of a repository

This is a deliberate choice, not laziness about packaging.

Software is trending toward disposable and single-use. A great deal of what gets published as a repo is something almost no one else will actually run, because running someone else's build means trusting it, and the thing you are building here holds a personal inventory of what is in your house, points photos of your basement at a vision model, and reaches out onto the open web on your behalf. Reasonable people are skeptical of letting an unfamiliar codebase touch any of that. No one wants a stranger's project quietly opening a backdoor or hauling in a dependency tree they will never audit.

A PRD sidesteps all of it. You hand this brief to an agent you already trust, in an environment you control, and it builds something you can read and own end to end. You adapt the data model to how you think about your bottles, swap the models for whatever you run, and decide your own safety posture. You get the design and the hard-won integration knowledge without importing anyone's dependencies, assumptions, or risk. The thing you end up with is yours, not a fork of ours.

And here is the part that matters most for a hobby tracker: there are perfectly good catalog apps out there, and most of them want a few dollars a month, forever, to hold a list of things you already own. The recipe is a more generous gift than renting you the kitchen. If you build something good from this, that is the entire point. There is nothing to star and no one keeping score.

The problem

There is no shortage of apps for cataloging a spirits collection. The trouble is what they ask of you, and it comes down to two real objections:

What you actually want is to grab your data and own it. A list of what you have, made easy to build (you should not have to type every field off a label by hand), pleasant to browse, and yours to keep, running on hardware you already have, with no monthly bill and no external service that can disappear.

The vision

A personal catalog whose superpower is photo intake: snap a bottle's label, a local vision model reads it, you confirm, and it joins a private collection. That collection is browsable two ways, as a working admin where you edit, enrich, and photograph, and as a quiet, public showcase that is image-first and read-only, a conversation piece you can hand someone a link to.

Underneath, it is honest about what a thing is versus what you own: an expression (the product) and the bottles of it on your shelf (sealed or open, however many, which barrel). It fills in the blanks for you by searching the open web and citing what it found, but it treats the label on the bottle as ground truth and never overwrites your own words. Photos get cleaned up locally so a messy phone snapshot becomes a tidy product shot. And it runs entirely on your own hardware, no external AI APIs, no subscription, no telemetry, your data on your disk.

One stance worth stating up front, because it shapes everything: pricing and value tracking are deliberately out of scope. This is a conversation piece, not an investment tracker. That is not a gap we are apologizing for; it is a design decision, and the section below explains why it is the right one.

Core capabilities

Each capability below is described as an outcome, not an implementation.

1. Photo → label intake, with a confirm step

The everyday way a bottle gets into the catalog: you photograph the label, a local vision model reads it, and the add form arrives pre-filled. You glance, fix anything off, and save.

A few choices made this work rather than frustrate:

2. The expression-vs-bottle data model

This is the structural idea the whole catalog hangs on, and getting it right early saves a painful migration later.

The batch/barrel/lot fields are what let you tell same-name bottles apart honestly. Two bottles of the same single-barrel product from different barrels are two bottles under one expression (record the barrel number per bottle). A genuinely different product line, the Gold release versus the Original, is a new expression. Encode that rule into your "you already own this, add as another bottle?" prompt at save time, and you will avoid both of the failure modes: spurious duplicate products, and quietly collapsing two distinct releases into one.

Two more lessons learned the honest way. First, size lives on the bottle, not the product: the same gin in a 750 ml and a 1 L is one expression with two bottles, each recording its own size; don't let a format difference spawn a duplicate product. Second, capture loose, then consolidate: when you are adding a whole shelf, it is faster to let each photo land as its own record and tidy up afterward than to perfect the match at intake. So give yourself a merge action (fold several records into one canonical expression, moving their bottles across) and lean on it. The corollary matters: a same-name match is not proof of a same product. Two "Blanton's" can be the Original and the Gold Edition; verify before you merge, and never auto-collapse on the name alone.

3. Web enrichment that cites its sources

When a bottle is saved with blank fields (and label reads always leave some blanks), the catalog goes and looks the bottle up on the open web, fills the gaps, and tells you exactly where each fact came from.

The shape of it: search → fetch → extract → cite. A search engine finds candidate pages; the results are ranked (the maker's own domain first, a short allowlist of review and retailer sites next, obvious noise dropped); the best official page is fetched in full; and a local LLM reads the gathered text and returns only the facts the sources actually state. It stores the distillery URL, the product-page URL, and a bulleted Sources list, so every claim is auditable.

The rules that keep it trustworthy:

4. The public showcase

A separate, read-only face on the same collection: image-forward cards in a browsable grid, a faceted filter (category, distillery, region, age, ABV) generated from what you actually own, and a per-bottle detail page. The detail page leads with the photo, lays out the specs, shows the producer's "from the label" prose, and underneath it the cited sources. It is the version you send a friend a link to, a quiet, handsome catalog, with the owner-only fields (what you paid, where it is physically stored) simply absent.

A note on how to build that safely: the public surface should be its own process that registers only the public, read-only routes: the admin and every editing path physically absent from it, not merely hidden behind a login. "The code to delete a bottle isn't loaded in this process" is a much stronger guarantee than "the delete button is auth-gated."

5. Image handling

Two image jobs, both local:

6. The private wishlist

A bottle you want is captured exactly like one you own: snap the label in a shop, the model reads it, you confirm, and it lands on a private Wishlist tab in the admin. The same intake, the same web enrichment, the same fields. The only difference is that it never reaches the public showcase, and the day you buy the bottle you promote it into the collection with one action, no re-entering anything, because the record is already complete. (After promoting, you usually only want to re-photograph it, since the wild shop photo is rarely the keeper.)

The privacy here is worth dwelling on, because the cheap way to build it is also the wrong way. Do not add a "private: yes/no" flag the public query has to remember to check: that is a filter you can forget, and forgetting it leaks. Instead, reuse the review-state the catalog already has. Every bottle carries a state: draft while you are still cleaning it up, confirmed once it is real and catalogued. The entire public surface already shows only confirmed bottles. So a wishlist item is simply a bottle in a third state, wishlist, and it is private by construction: it is not confirmed, and the public process only ever asks for confirmed, so there is no code path that could surface it. Promoting is then one field flip, wishlist → confirmed. You did not build a privacy feature; you reused an exclusion the architecture already guaranteed. That is the pattern to reach for: make the unwanted state unreachable, rather than adding a guard you have to maintain.

7. Cocktails: recipes for what you own, and originals from your shelf

A collection is for pouring, so the catalog earns its keep by answering "what can I make with this?" It does that in three layers, each more adventurous than the last, and one rule runs through all of them: a real recipe and an invented one must never be mistaken for each other.

The honesty fence. This is the "label is truth" of the cocktails feature: an invented cocktail is never allowed to pass for a real one. Give every recipe an explicit tier: signature (real, names this bottle), classic (real, canonical for the type), and invented (AI-generated). Let the tier drive a visible badge wherever a recipe appears. A saved invention keeps its own tier, lists under its own "Invented" heading, never mingles with the real recipes, and wears copy that calls it what it is: an original idea, untested, pour at your own risk. The failure you are guarding against is a plausible fabrication getting made as though a bartender vouched for it.

Conceptual data model

Described as entities and the fields that carry meaning. Use whatever storage you like; any relational store is plenty.

The public/owner-only split is the only access rule you need: identity and story are public; what you paid and where it physically sits are not.

The capture pipeline reality

The intake path, end to end: camera → downscale → local VLM OCR → structured fields → human confirm.

The enrichment reality (worth reading twice)

This is the part that separates a useful catalog from a pretty one, and the part most likely to go subtly wrong, so it earns a careful read.

The pipeline: search → rank → fetch → extract → cite.

  1. Search. Query a web search engine with the bottle's identity ("{name} {distillery} {category}"), and run a second pass adding something like tasting notes if the first is thin.
  2. Rank into tiers. Tier 1: the maker's own domain (the result whose domain matches a normalized token of the distillery or brand). This is the authoritative source and the one you most want. Tier 2: a short allowlist of reputable review and retailer sites. Everything else: dropped. A naive "just feed the model the top results" approach will pull in a restaurant menu, a big-box store listing, and a vacation rental that happened to share a word with the bottle's name; ranking is what keeps that junk out.
  3. Fetch: the content pages, not just the official one. This is the correction that mattered most. The early instinct is to crawl the maker's own page; in practice that page is often a thin JavaScript marketing shell, while the actual specs (mash bill, age, and proof) live on the review and database sites. Crawl the full text of the top few distinct domains (dedupe by domain, or an official site that returns five results will eat every slot), keep snippets of the rest, and cap how much text you keep. Two more realities worth designing for: some sites (big retailers, aggregators) sit behind bot-protection that hard-fails any crawler (detect that and fall back to their snippet rather than burning a fetch slot), and a brand-only search buries the good content sites under the official domain, so run a second, review-oriented query ("{name} review tasting notes") and merge the results.
  4. Extract, and be careful what you call "known." Hand the gathered text plus the bottle's known facts to a local LLM and ask it for structured JSON of only the fields the text supports: description, flavor notes, category, region, ABV, age, cask, mash bill, and the source links. A strict prompt: only what is stated, no invention, ignore irrelevant snippets, and never a price field. And the trap that cost us a long debugging session: when you give the model the bottle's known facts for context, give it only the human-facing identity fields, never the whole database row. Feed it your own bookkeeping (internal IDs, timestamps, and above all the previous enrichment's stored JSON), and the model reads that as "this is already done" and returns nothing. The symptom is maddening and easy to misread: freshly added bottles enrich fine, but re-running enrichment on anything already touched reports "nothing to add," and a bulk backfill quietly fills zero. Keep the KNOWN context to name, distillery, brand, category, region, ABV, age, cask, mash, and nothing else.
  5. Cite. Store the distillery URL, the product URL, and the list of sources, and show them.

The one rule that governs all of it: the label is truth, the web is a draft. What is printed on the bottle, and what you typed, outranks anything the web says. Enrichment fills blanks and proposes corrections as advisories; it never silently rewrites the bottle's own facts. Build that contract in from the first line and never relax it.

Why pricing is excluded. It is not an oversight and it is not a "phase 2." Two reasons, and they reinforce each other. First, the philosophy: this is a catalog of things you own and enjoy, a conversation piece. The moment it becomes a portfolio with a running dollar total, it changes what the collection is for, and not in a direction we wanted. Second, the practicality (see the data-source section): real, current secondary-market pricing is paywalled or ToS-locked everywhere credible, so "value tracking" would mean either a recurring paid data feed (the very subscription you left to build this) or scraping someone in violation of their terms. Excluding pricing is what lets the whole thing run free, local, and clean. If you ever genuinely want a value number, leave a slot in the schema for a manual observation you log by hand, but keep it out of enrichment entirely.

The failure modes, and how to guard them.

An escape hatch for the bottles the web can't reach. A small number of bottles defeat the pipeline no matter how good it is: a genuinely web-dark small producer, or a famous one whose only pages are JavaScript shells that crawl to nothing. For those, keep a manual deep-dive path: a person (or an agent you drive) researches the specific bottle by hand, identifies what makes it special (a commemorative edition, a limited release), and writes a rich, sourced record directly. It is the white-glove complement to the automated pipeline, not a replacement; reserve it for the bottles you care about that the automation leaves thin.

Run it as a background job. Search plus a page fetch plus an LLM extraction easily blows past the ~100-second timeout that a reverse proxy or tunnel (Cloudflare's default is the classic one) will impose on a single request. So enrichment must be a background job you poll: kick it off, get a job id, poll for progress, collect the result. A bottle can auto-enrich in the background the moment it is saved, with no one waiting on a spinner. (This same async pattern applies to the label OCR reads, for the same reason.)

The cocktails reality

Recipe enrichment reuses the same search → rank → fetch → extract → cite spine as fact enrichment, but recipe pages behave differently enough that they need their own tuning, and the invention layer adds a few rules of its own.

For the invention layer specifically:

Data-source reality

A word on what actually exists out there, because the honest picture saved us from building on sand. The short version: identity, reference, flavor, and recommendations are all buildable on free and open data; current pricing is the one genuine wall.

What is open and useful:

What is paywalled or ToS-locked:

The honest gaps: collector-grade reference data for non-whisky (rum, agave, gin, brandy) is thin in open datasets, which skew heavily whisky; for those you hand-build the taxonomy from Wikidata and the regulatory bodies. And pricing, as said, you either log by hand or do without. Our take: do without, and let that be the feature.

Suggested build order

Each phase ships something usable and de-risks the next:

  1. The catalog core, local-only. The expression-vs-bottle data model, a faceted library list, a manual "add a bottle" form, a detail page (view / edit / delete), and a merge action (fold duplicate expressions into one, moving their bottles over) for the capture-loose / consolidate-later workflow. No AI yet. This alone is a working inventory you own.
  2. Photo intake. Wire in the local vision model: upload or capture a photo, a deliberate "Read this label" button, structured pre-fill with per-field confidence, the front/back merge, and the match-on-save prompt (same product → another bottle, or new expression). This is the superpower; get it solid before anything else.
  3. Image knockout. Stand up the local CPU background-removal service, auto-fire it on photo ingest, and write transparent cutouts. Now your photos look like product shots.
  4. Web enrichment. Build the search → rank → fetch → extract → cite pipeline as a background job, with the never-clobber contract, the parity advisories, and pricing firmly excluded. Auto-enrich new bottles on save; run a one-time backfill over what you already have.
  5. The public showcase. A separate read-only process: image-forward cards, faceted browse, per-bottle detail with sources, owner-only fields omitted. Front it however you expose things to the world.
  6. Cocktails. Per-bottle recipe enrichment on the same pipeline, tuned for recipe pages (a bigger text window, a base-spirit precision guard, brand-stripped dedup, a distillery-site seed, and a per-bottle time budget), storing real recipes in two labeled tiers. Then a mixologist browse across the whole shelf. Then, if you want the adventurous layer, off-menu invention behind the honesty fence, grounded to owned bottles, and rate-limited if it faces the public.
  7. (Optional polish) Hero generation on a GPU, recommendation ("what to pour") on the flavor vectors, and a manual value-observation field if you decide you want one.

Roles you will need to choose

To keep this stack-agnostic, here are the roles, each one's job, not our pick. You may map two roles onto one piece of software, or split them across machines.

The whole point of the role list is that every one of these can be something local and free. No external AI API appears anywhere in the chain; the only standing cost is a couple of small always-on containers.

Principles that kept the build honest

Prior art and resources

You do not have to invent the taxonomy or the recommender from scratch; there is real public work to stand on, and pointing you to it is the opposite of exposing a private codebase:

These are public community resources, not our build. Stand on them, then go your own way.

Working with your AI agent (starter prompts)

This brief is meant to be handed to an AI coding agent, not read like a manual. Below is a sequence of starter prompts that mirrors the suggested build order. Paste the whole brief into your agent first, then drive it phase by phase. Adapt the bracketed parts to your stack and your collection. These are a starting point, not a script.

0. Orient the agent and choose your stack.

I'm giving you a product brief for a personal, self-hosted spirits-collection catalog with photo-label intake. I want to build my own version for myself, with no subscription and my data on my own hardware. My environment is [language / framework / database / local vision model / local LLM]. Read the whole brief, then propose an architecture and a phase-1 plan that follows the suggested build order. Flag anywhere my stack changes the brief's assumptions, and ask me anything you need before you write code.

1. The catalog core, local-only.

Build phase 1 from the brief with no AI yet: the expression-vs-bottle data model (an expression is what a thing is; a bottle is what I own, with batch/barrel/lot to tell same-name bottles apart; quantity is the count of bottle rows), a faceted library list, a manual add-a-bottle form, and a detail page to view, edit, and delete. Generate the schema and the core screens.

2. Photo intake (the superpower).

Wire in a local vision model for label OCR. Let me upload or capture a photo, and read it only on a deliberate "Read this label" button (not automatically on upload). Pre-fill the add form with a per-field confidence badge: confident where it read printed text, "needs you" where it guessed. Read front and back faces and merge them (front wins identity, back wins production details and the producer's "from the label" prose; exclude the government warning). On save, match against what I own: same product → "add as another bottle?", genuinely different line → new expression. Have the model return null for facts the label doesn't state; never fabricate a number.

3. Clean up the photos.

Add a local CPU background-knockout service that, on photo ingest, writes a transparent product cutout next to the original (never modifying the original). Show the cutout floated on a styled backdrop in the catalog.

4. Web enrichment that cites its sources.

Build enrichment as a background job I poll (it exceeds a reverse-proxy timeout): for a bottle, search the web for "{name} {distillery} {category}" plus a second "{name} review tasting notes" pass, rank results (the maker's own domain first, a category-appropriate review/retailer allowlist next, drop the rest), and crawl the full text of the top few distinct domains, not just the official page, which is often a thin JS shell while the real specs live on review sites. Have a local LLM extract only the facts the sources state (description, flavor notes, region, ABV, age, cask, mash) plus the source links, and pass it ONLY my bottle's identity fields as context, never the whole DB row or any prior enrichment JSON (that makes the model think it's already done and return nothing). Store the distillery URL, product URL, and a cited Sources list. Fill any blank field, specs included; never overwrite my label reads or hand edits; surface web-vs-existing disagreements as admin-only "web says X · Use" advisories. Never search, extract, or store a price. Auto-enrich new bottles on save, and give me a one-time backfill over the existing collection. If a name or brand is ambiguous and the search returns junk, retry leading with the category.

5. The public showcase.

Build a separate, read-only process that registers only the public routes (the admin and all editing paths physically absent from it, not just auth-gated): image-forward cards, faceted browse generated from what I own, and a per-bottle detail page leading with the photo, then specs, then the producer's prose, then the cited sources as plain links. Omit owner-only fields (what I paid, where it's stored).

6. Cocktails, real recipes, a mixologist view, and off-menu invention.

Add cocktails in three layers. First, per-bottle recipe enrichment on the same search/rank/fetch/extract/cite pipeline, but tuned for recipe pages: keep a much larger text window (recipes sit below the marketing chrome), guard that a scraped drink actually uses the bottle's base-spirit family (drop cross-product leaks; exempt liqueurs and mixers), strip my brand and distillery tokens before de-duplicating titles, and seed the crawl from the bottle's own product/distillery URL before searching. Store real recipes in two labeled tiers: Signature (names this bottle) and Classic (canonical for the type, as a fallback). Validate that any record-supplied URL resolves to a public address before fetching it. Second, a mixologist view that browses all my recipes as one pool, filterable by spirit type, cocktail flavor, tasting note, and tier, with a sensible default order and tier headings. Third (optional), off-menu invention: one local-LLM call that invents a few original cocktails built only from bottles I own, each attached to every bottle it uses, badged "Invented" and never mixed in with the real recipes. If that endpoint is public, rate-limit it on an unforgeable client IP, and don't let saving one invention reload away the others.

A caution worth pasting when relevant:

The label on the bottle is ground truth; anything from the web is a draft that defers to it. Never let enrichment overwrite a value I read off the label or typed myself: fill blanks only, and propose everything else as an advisory I approve. And if there's no good source for a bottle, leave it untouched rather than writing a guess.


This brief documents the project through photo intake, the expression-vs-bottle model, web enrichment with cited sources, local image knockout, the public showcase, the private wishlist, and cocktails (real-recipe enrichment, a mixologist browse, and fenced off-menu invention). Pricing is out of scope by design, this is a conversation piece, not an investment tracker.