# Catalogue Admin — Design

**Date:** 2026-08-11
**Status:** Draft, awaiting review
**Scope:** Phase 3 of seven. Depends on Phase 2 (pairing), built and live-verified.

---

## 1. Purpose

Make the pairing graph visible and correctable by a human.

**The number that justifies this phase: 229 pairs are queued for approval and
nothing can approve them.** Phase 2 built the queue, the decision rule and the
endpoints; it built no way for a person to answer. The approval step — the thing
that was asked for from the beginning — is currently unreachable.

**Success criteria:**

1. A person can browse categories, open a product, and see its four pairing
   types with the reason each was chosen.
2. A person can work through the approval queue and approve or reject in batches.
3. A decision made in the UI survives a re-run of the pairing job.
4. No build step, no dependencies, no frontend infrastructure added to this
   service.

## 2. What this is, and what it is not

**This is an internal tool.** One self-contained HTML file, in the same spirit as
`tests/chat_ui.html`, which is how this project already does throwaway UI.

**It is not the merchant-facing product.** This service is a Python API with no
frontend infrastructure whatsoever — no static mount, no template engine, no
build step, no component framework. The real merchant dashboard belongs in the
existing admin application alongside the other integration screens, and adding a
frontend toolchain here to fake that would be the wrong trade.

What this tool does is let a real person look at 2,114 real pairs and answer the
only question that matters right now: **is the pairing any good?** No amount of
test coverage answers that, and the live hand-checks in Phase 2 could only sample
it.

Two screens, not the four the matching document describes. Connect-and-sources
and the catalogue health view are conveniences over endpoints that already work;
the pairing view and the approval queue are the two without which the phase's
own output is inaccessible.

## 3. The blocking backend gap

The endpoints return **product keys and nothing else**:

```json
{"anchor_key": "http_api:dummyjson.com:161",
 "neighbor_key": "http_api:dummyjson.com:175",
 "pair_type": "bundle", "score": 0.8, "confidence": 0.5,
 "reasons": ["3 complements from distinct categories", ...]}
```

No merchant can approve `…:161 → …:175`. They need to see *a 34,999 tablet
suggesting a 5,897 set of accessories*, with names and images.

**So Phase 3 is mostly a backend change, not a UI one.** `pending_pairings` and
`pairings_for` must resolve both sides of every pair into a card — name, image,
price, category — in one query. The alternative is the UI making an N+1 storm of
`/catalog/products/{key}` calls, which would be slow and would put presentation
logic in a throwaway file.

This gap existed in Phase 2 and no test caught it, because every test asserted on
shape and the shape was correct. It is only visible when a person tries to read
the output.

## 4. Screens

### 4.1 Catalogue and pairings

```
┌────────────┬──────────────────────────────────────────────┐
│ categories │  [product cards, paged]                      │
│            │                                              │
│ beauty  27 │  ┌────────┐  clicking a card opens it below  │
│ tablets  8 │  │ image  │                                  │
│ …          │  │ name   │  ── pairings ──────────────────  │
│            │  │ price  │  SIMILAR    (3)  ▸ cards         │
│            │  └────────┘  COMPLEMENT (8)  ▸ cards         │
│            │              UPSELL     (5)  ▸ cards         │
│            │              BUNDLE     (3)  ▸ cards         │
└────────────┴──────────────────────────────────────────────┘
```

Each pairing card shows the neighbour's image and name, the score, and **the
reasons, verbatim** — "same category (tablets)", "0.70 text similarity", "0.20
attribute overlap". The reasons are the whole point: a merchant judging a pair
needs to see why it was proposed, and a bare 0.55 tells them nothing.

Each card also shows whether it is currently **servable** or **queued**, so the
distinction the approval rule makes is visible rather than implied.

### 4.2 Approval queue

```
┌───────────────────────────────────────────────────────────┐
│ 229 pending          [approve all visible] [reject all]   │
├───────────────────────────────────────────────────────────┤
│ ☐  [img] iPad Pro  →  [img] Charging Cable   BUNDLE  0.80 │
│        3 complements from distinct categories             │
│        set costs 5,897 against anchor 34,999              │
│        [approve]  [reject]                                │
└───────────────────────────────────────────────────────────┘
```

Both sides shown as cards, the reasons underneath, and per-row buttons plus a
batch action. `POST /catalog/pairings/decide` already accepts a batch, which is
why the screen can work this way — a person clearing 229 rows one request at a
time would give up.

**Approved and rejected rows leave the queue immediately** and the count
decrements, so progress is visible. A queue that does not visibly shrink is a
queue nobody finishes.

## 5. How it is served

`GET /catalog/admin` returns the HTML file directly.

Not opened as a `file://` page, which is how `chat_ui.html` works: the browser
would send an `Origin: null` on every API call and the CORS configuration here
sets `allow_credentials=True`, which makes wildcard origins unreliable. Serving
the page from the API makes every call same-origin and removes the entire
problem rather than working around it.

The route reads the file from disk on each request, so editing the HTML and
refreshing is the whole development loop. No build step, no restart.

## 6. Design constraints

**One file, no dependencies.** No React, no bundler, no CDN. Plain HTML, plain
CSS, plain `fetch`. It must keep working with no network access to anything but
this API.

**Tenant chosen in the UI**, defaulting to the live tenant, as a plain text
field. There is no auth on these endpoints and this tool adds none — it is a
local tool against a local API, and pretending otherwise by adding a login box
would suggest a protection that does not exist.

**Every number shown as it is stored.** Prices in minor units are rendered with
their currency, scores to two decimals. No rounding that hides a difference the
merchant is being asked to judge.

**Failures are shown, not swallowed.** If a request fails the screen says so with
the status code. A silent empty state that looks like "no pairings" when it was
really a 502 is how someone concludes the pairing is broken when the server is.

## 7. Endpoints

No new endpoints except the page itself. Two existing ones change shape.

```
GET  /catalog/admin                       (new) the page
GET  /catalog/categories                  unchanged
GET  /catalog/products                    unchanged
GET  /catalog/products/{key}/pairings     (changed) neighbours resolved to cards
GET  /catalog/pairings/pending            (changed) both sides resolved to cards
POST /catalog/pairings/decide             unchanged
POST /catalog/pair                        unchanged
```

The changed shape, additive so nothing that reads the old fields breaks:

```json
{"anchor_key": "…:161", "neighbor_key": "…:175",
 "pair_type": "bundle", "score": 0.8, "confidence": 0.5,
 "source": "attribute", "reasons": ["…"], "servable": false,
 "anchor":   {"name": "iPad Pro",       "image_url": "…", "price_cents": 34999,
              "currency": "USD", "category": "tablets"},
 "neighbor": {"name": "Charging Cable", "image_url": "…", "price_cents": 5897,
              "currency": "USD", "category": "mobile-accessories"}}
```

`pairings_for` already knows the anchor, so it carries `neighbor` only.

## 8. Error handling

| Condition | Behaviour |
|---|---|
| A pair references a product deleted since the last run | dropped at read time, with the count of dropped rows in the response — the catalogue changes between runs |
| A tenant has no pairs | 200 and an empty state that says so, distinct from a failure |
| `decide` receives an invalid decision value | 422, already implemented, surfaced in the UI as the message |
| The API is unreachable | the screen says which call failed and its status |
| A product has no image | a placeholder, not a broken image icon |

## 9. Testing

**The backend change is tested; the HTML is not.** A single-file internal tool
does not earn a browser test harness, and adding one would cost more than the
tool. What is tested is the thing that can silently break:

- a resolved pair carries both sides' names, images and prices
- resolving does not N+1 — one query per request regardless of pair count,
  asserted by counting queries
- a pair whose neighbour was deleted is dropped, and the drop is counted
- `servable` is present on every row and matches `is_servable`
- the existing keys (`anchor_key`, `score`, `reasons`, …) are all still present,
  so the change is additive
- `GET /catalog/admin` returns HTML and a 200

**Manual verification** is the point of the phase and is stated as a step rather
than pretended to be automatic: open the page against the live 218-product
catalogue, work through a dozen queue rows, re-run the pairing job, and confirm
the decisions survived.

## 10. Known limitations

**No authentication.** These endpoints have none and this adds none. It must not
be exposed publicly.

**No editing of products or attributes.** Read and decide only. A merchant who
sees a wrong attribute cannot fix it here; that is a catalogue problem and
belongs upstream.

**No bundle-level view.** Bundles are stored as one row per member, so the queue
shows three rows for a three-item set rather than one set to approve. Grouping
them is the obvious next improvement and is deliberately not in this phase —
it changes the decision model, since approving two of three members is a state
the schema cannot currently express.

**Not the merchant product.** See §2. Nothing here is styled, translated, or
built to be shown to a customer.

## 11. Out of scope

Online ranking (Phase 4); measurement (Phase 5); the production merchant
dashboard; authentication; per-variant stock; catalogue editing.
