# Firmographic Cluster Semantic Labels — Design
Date: 2026-06-11 Status: Approved (brainstorm) — pending spec review Repo: lead-contagion-project (Lyréco tenant)
Goal
Give each firmographic cluster (the KMeans communities in
account_firmographic_community, k=60 per country) a human-readable name + a
one-line "what it's made of" description, so marketers read *"Mid-spend
South-East professional services" instead of "cluster 37."* Names/descriptions
are hybrid-generated: facts computed deterministically, phrasing by the LLM.
Decision (brainstorm)
Naming method = hybrid. Compute each cluster's defining traits
deterministically, then claude -p turns the factual summary into a polished
name + description. Facts are reproducible; wording is human and marketer-facing.
Components
1. Stat profiler (deterministic — the "made of")
For each (source_country, community_id), aggregate its members from
customer_firmographic_features (joined to account_firmographic_community on
account_number, source_country) into a compact factual profile:
- member_count — accounts in the cluster.
- dominant_sector — the modal
accounts_category(fallbacksic_group) +
sic_meta_sectors. e.g. `"professional
services" (62%)`.
- spend_tier — banded from the cluster's median
annual_spend:
micro < £1k · small £1k–10k · mid £10k–50k · large ≥ £50k (GB £; thresholds
are module constants, tunable).
- size_band — from median
n_users: `solo 1 · small 2–5 · mid 6–20 ·
- dominant_region — top
postcode_area(fallbackregion_key) + share. - churn_share — % of members with
is_churn = true.
Reproducible and auditable: every claim in a description traces back to a number.
Implemented as a pure function profile_cluster(member_rows) -> dict over the
fetched rows, so it is hermetically testable and free of DB/LLM coupling.
2. LLM namer (phrasing)
One batched claude -p call for all clusters (not one per cluster). Input =
JSON list of the stat-profiles. Output = JSON array of
{community_id, name, description} where:
name≤ 4 words, title-case, marketer-friendly;description≤ 1 sentence, plain language, only traits present in the
Mechanics: claude -p with stdin=subprocess.DEVNULL (the console-less-service
stdin-hang gotcha), subscription-billed (quota, not API). The prompt instructs
strict JSON-only output. Validate the returned JSON (shape + every
community_id present); if it fails or the CLI errors, fall back to a
rule-based name/description assembled from the top features, so a row is never
left unlabeled.
3. Storage
New Postgres table:
`sql
CREATE TABLE IF NOT EXISTS firmographic_community_labels (
source_country TEXT NOT NULL,
community_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
member_count INTEGER NOT NULL,
top_sector TEXT,
spend_tier TEXT,
size_band TEXT,
dominant_region TEXT,
churn_share NUMERIC(5,2),
generated_by TEXT NOT NULL DEFAULT 'llm', -- 'llm' | 'rule_fallback'
last_computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_country, community_id)
);
`
Stores the human label and the stats behind it (auditable + reusable).
4. Worker + cadence
workers/label_firmographic_communities.py --country GB:
1. fetch members + features per cluster,
2. profile_cluster each → stat-profiles,
3. batched claude -p → names/descriptions (with rule-based fallback),
4. idempotent write (delete-then-insert per source_country).
Runs after community_match_soft (the clusterer) — chained in
daily_refresh when communities are recomputed, or run manually.
Reproducibility caveat (pin): KMeans community_id is not stable across
rebuilds — a re-run of community_match_soft can renumber clusters. Labels
therefore regenerate each time the clusters do; a label describes the current
composition, not a durable cluster identity.
5. Surfacing
- Cluster rollup report (
scripts/export_buying_profiles.py): replace the
"Firmographic cluster {id}" heading with "{name} — {description}" via a
LEFT JOIN to firmographic_community_labels (NULL-safe: fall back to the id).
- Buying Profile panel (
/panel/buying-profile): show the account's cluster
account_firmographic_community → labels for the
resolved account.
6. Testing
- Hermetic unit tests on
profile_cluster(synthetic member rows → expected
- The LLM step is integration-only (skipped without the CLI / in CI).
Data-flow notes
- All inputs/outputs are Postgres (
customer_firmographic_features,
account_firmographic_community, sic_meta_sectors,
firmographic_community_labels) — single-DB, no cross-DB join.
claude -pbilling = subscription quota; one batched call keeps it cheap.
YAGNI cuts
No manual-override UI (edit the row directly if a label is off — generated_by
flags provenance). No per-cluster LLM calls. No FR until its communities exist
(GB-only now). No durable cluster identity across rebuilds.
Open / tunable
- Spend-tier £ thresholds (module constants) — first pass as above, tune on
- Whether to add the panel-header cluster name now or defer — included now