Background
When a brief is uploaded, intake tries to determine the customer. If neither the LLM nor the Customer: regex yields an id, the background finisher assigns the brief to a fallback customer literally named "UNCATEGORIZED" and creates that customer row:
backend/app/api/intake.py:146 — new_cust = (parsed.get("customer_id") or "").strip() or "UNCATEGORIZED"
backend/app/api/intake.py:214 — same fallback in the upload path.
There is a separate synthetic bucket, _PENDING, used while an upload is still resolving.
Problem / Goal
The customer list and the delete-protection set only account for _PENDING, not UNCATEGORIZED:
backend/app/api/customers.py:30 filters the listing with where(Customer.id != "_PENDING") — so UNCATEGORIZED does show up in the customer list as if it were a real customer.
backend/app/api/customers.py:19 — SYSTEM_CUSTOMERS = {"LEGACY", "_PENDING"} — UNCATEGORIZED is not protected, so it's user-deletable even though the system recreates it on the next unresolved upload.
Result: a synthetic bucket masquerades as a customer, silently accumulates every brief the extractor couldn't attribute, and can be deleted only to reappear. This is confusing and makes it easy to lose track of unattributed briefs.
Problem / Goal (intended behavior)
Treat UNCATEGORIZED as a system-managed bucket, consistently, everywhere _PENDING and LEGACY are handled — OR surface it deliberately and distinctly (a labeled "Unattributed" group) rather than as a normal customer. Pick one and apply it consistently.
Where to look
backend/app/api/intake.py:146, 214 — where UNCATEGORIZED is minted.
backend/app/api/customers.py:19, 30, 61 — SYSTEM_CUSTOMERS, the listing filter, and the delete guard.
- Define the fallback id once as a named constant (like
PENDING_CUSTOMER_ID at intake.py:44) instead of a bare string literal in two places.
Suggested approach
- Introduce a shared constant
UNCATEGORIZED_CUSTOMER_ID = "UNCATEGORIZED" (co-locate with PENDING_CUSTOMER_ID) and use it at both intake call sites.
- Decide the product behavior:
- Option A (hide like
_PENDING): add it to the listing exclusion and to SYSTEM_CUSTOMERS. Then add a dedicated way to review unattributed briefs (e.g. they already show under the customer detail view) so they aren't lost.
- Option B (surface distinctly): keep it visible but add it to
SYSTEM_CUSTOMERS so it can't be deleted, and label it clearly in the UI as the unattributed bucket.
- Whichever option: it must not be freely deletable-then-auto-recreated.
Acceptance criteria
- The fallback customer id is a single named constant, not a repeated literal.
UNCATEGORIZED cannot be deleted via DELETE /api/customers/{id} (returns the same 409 as _PENDING/LEGACY), OR the product deliberately allows it and the UI reflects that — decided and documented in the PR.
- Its treatment in the customer listing is intentional and consistent with the chosen option.
- Unattributed briefs remain discoverable.
Testing
Local backend (cd backend && uvicorn app.main:app --reload --port 8080):
POST /api/intake/text with text that has no Customer: line and nothing the LLM can attribute; wait for status=ready.
GET /api/customers — confirm UNCATEGORIZED is handled per the chosen option (hidden, or clearly labeled).
DELETE /api/customers/UNCATEGORIZED — confirm it's refused (Option A/B both protect it).
Out of scope
- Reworking the whole customer-attribution flow or the
_PENDING handoff.
- Frontend visual design beyond the minimal label needed for the chosen option.
Background
When a brief is uploaded, intake tries to determine the customer. If neither the LLM nor the
Customer:regex yields an id, the background finisher assigns the brief to a fallback customer literally named"UNCATEGORIZED"and creates that customer row:backend/app/api/intake.py:146—new_cust = (parsed.get("customer_id") or "").strip() or "UNCATEGORIZED"backend/app/api/intake.py:214— same fallback in the upload path.There is a separate synthetic bucket,
_PENDING, used while an upload is still resolving.Problem / Goal
The customer list and the delete-protection set only account for
_PENDING, notUNCATEGORIZED:backend/app/api/customers.py:30filters the listing withwhere(Customer.id != "_PENDING")— soUNCATEGORIZEDdoes show up in the customer list as if it were a real customer.backend/app/api/customers.py:19—SYSTEM_CUSTOMERS = {"LEGACY", "_PENDING"}—UNCATEGORIZEDis not protected, so it's user-deletable even though the system recreates it on the next unresolved upload.Result: a synthetic bucket masquerades as a customer, silently accumulates every brief the extractor couldn't attribute, and can be deleted only to reappear. This is confusing and makes it easy to lose track of unattributed briefs.
Problem / Goal (intended behavior)
Treat
UNCATEGORIZEDas a system-managed bucket, consistently, everywhere_PENDINGandLEGACYare handled — OR surface it deliberately and distinctly (a labeled "Unattributed" group) rather than as a normal customer. Pick one and apply it consistently.Where to look
backend/app/api/intake.py:146, 214— whereUNCATEGORIZEDis minted.backend/app/api/customers.py:19, 30, 61—SYSTEM_CUSTOMERS, the listing filter, and the delete guard.PENDING_CUSTOMER_IDatintake.py:44) instead of a bare string literal in two places.Suggested approach
UNCATEGORIZED_CUSTOMER_ID = "UNCATEGORIZED"(co-locate withPENDING_CUSTOMER_ID) and use it at both intake call sites._PENDING): add it to the listing exclusion and toSYSTEM_CUSTOMERS. Then add a dedicated way to review unattributed briefs (e.g. they already show under the customer detail view) so they aren't lost.SYSTEM_CUSTOMERSso it can't be deleted, and label it clearly in the UI as the unattributed bucket.Acceptance criteria
UNCATEGORIZEDcannot be deleted viaDELETE /api/customers/{id}(returns the same 409 as_PENDING/LEGACY), OR the product deliberately allows it and the UI reflects that — decided and documented in the PR.Testing
Local backend (
cd backend && uvicorn app.main:app --reload --port 8080):POST /api/intake/textwith text that has noCustomer:line and nothing the LLM can attribute; wait forstatus=ready.GET /api/customers— confirmUNCATEGORIZEDis handled per the chosen option (hidden, or clearly labeled).DELETE /api/customers/UNCATEGORIZED— confirm it's refused (Option A/B both protect it).Out of scope
_PENDINGhandoff.