← back to Rentv
pr-intelligence: coverage-scope exclusion (Steve — skip bank/credit_union from gate denominator, dashboard no-PR count, gap report); configurable via coverage_exclude_types setting + Settings UI; migration 003
bc9f1e6371e75ac000f13ab4d5e4ecba975a0092 · 2026-07-30 21:14:06 -0700 · Steve Abrams
Files touched
M public/admin/pr-intelligence/settings.htmlM src/pr/index.jsM src/pr/jobs/index.jsA src/pr/migrations/003_coverage_exclude.sqlM src/pr/services/settings.js
Diff
commit bc9f1e6371e75ac000f13ab4d5e4ecba975a0092
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 30 21:14:06 2026 -0700
pr-intelligence: coverage-scope exclusion (Steve — skip bank/credit_union from gate denominator, dashboard no-PR count, gap report); configurable via coverage_exclude_types setting + Settings UI; migration 003
---
public/admin/pr-intelligence/settings.html | 6 ++++--
src/pr/index.js | 10 ++++++----
src/pr/jobs/index.js | 4 +++-
src/pr/migrations/003_coverage_exclude.sql | 8 ++++++++
src/pr/services/settings.js | 15 ++++++++++-----
5 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/public/admin/pr-intelligence/settings.html b/public/admin/pr-intelligence/settings.html
index 6fb682bc..55e5319d 100644
--- a/public/admin/pr-intelligence/settings.html
+++ b/public/admin/pr-intelligence/settings.html
@@ -54,7 +54,8 @@
</div>`;
const EDITABLE = ['sender_name', 'sender_title', 'sender_contact_information', 'sender_postal_address',
- 'approved_submission_method', 'unsubscribe_text', 'letter_main_purpose', 'refresh_days_high', 'refresh_days_normal'];
+ 'approved_submission_method', 'unsubscribe_text', 'letter_main_purpose', 'refresh_days_high', 'refresh_days_normal',
+ 'coverage_exclude_types'];
async function loadSettings() {
const all = await PR.api('/settings');
document.getElementById('sform').innerHTML = EDITABLE.map((k) => {
@@ -66,7 +67,8 @@
document.getElementById('s-save').addEventListener('click', async () => {
for (const el of document.querySelectorAll('[data-k]')) {
const k = el.dataset.k;
- const v = /refresh_days/.test(k) ? Number(el.value) || 0 : el.value;
+ let v = /refresh_days/.test(k) ? Number(el.value) || 0 : el.value;
+ if (k === 'coverage_exclude_types') { try { v = JSON.parse(el.value); } catch { PR.toast('coverage_exclude_types must be a JSON array, e.g. ["bank","credit_union"]', true); continue; } }
await PR.api('/settings/' + k, { method: 'PUT', body: { value: v } });
}
PR.toast('Settings saved');
diff --git a/src/pr/index.js b/src/pr/index.js
index f5f4657d..e59f1b34 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -102,11 +102,12 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
const byMetro = await db.rows(
`SELECT m AS metro, count(*)::int AS n FROM pr_organizations, unnest(metros) m
WHERE lifecycle_status NOT IN ('duplicate','archived') GROUP BY 1 ORDER BY n DESC`, []);
+ const excludeTypes = await settings.get('coverage_exclude_types', ['bank', 'credit_union']);
const noPr = await db.one(
`SELECT count(*)::int AS n FROM pr_organizations o
- WHERE lifecycle_status NOT IN ('duplicate','archived')
+ WHERE lifecycle_status NOT IN ('duplicate','archived') AND organization_type <> ALL($1::text[])
AND NOT EXISTS (SELECT 1 FROM pr_people p WHERE p.organization_id=o.id
- AND p.department IN ('communications','agency') AND p.lifecycle_status NOT IN ('duplicate','suppressed'))`, []);
+ AND p.department IN ('communications','agency') AND p.lifecycle_status NOT IN ('duplicate','suppressed'))`, [excludeTypes]);
const agencies = await db.rows(
`SELECT o.id, o.display_name,
(SELECT count(*) FROM pr_org_relationships r WHERE r.source_organization_id=o.id AND r.relationship_type='agency_client')::int AS clients
@@ -128,8 +129,9 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
const caCells = await db.one(
`SELECT count(DISTINCT (m, organization_type))::int AS covered
FROM pr_organizations, unnest(metros) m
- WHERE 'CA'=ANY(state_presence) AND lifecycle_status NOT IN ('duplicate','archived')`, []);
- const totalCells = geo.CA_METROS.length * ORG_TYPES.length;
+ WHERE 'CA'=ANY(state_presence) AND lifecycle_status NOT IN ('duplicate','archived')
+ AND organization_type <> ALL($1::text[])`, [excludeTypes]);
+ const totalCells = geo.CA_METROS.length * (ORG_TYPES.length - excludeTypes.length);
res.json({
california: {
organizations: orgTotals.ca,
diff --git a/src/pr/jobs/index.js b/src/pr/jobs/index.js
index 97ec9728..94f68f54 100644
--- a/src/pr/jobs/index.js
+++ b/src/pr/jobs/index.js
@@ -642,6 +642,8 @@ const handlers = {
const state = job.payload.state || 'CA';
const metros = geo.metrosFor(state);
const { ORG_TYPE_KEYS } = require('../lib/taxonomy');
+ const excludeTypes = await settings.get('coverage_exclude_types', ['bank', 'credit_union']);
+ const scopedTypes = ORG_TYPE_KEYS.filter((t) => !excludeTypes.includes(t));
const grid = [];
for (const m of metros) {
const byType = await db.rows(
@@ -650,7 +652,7 @@ const handlers = {
FROM pr_organizations WHERE $1 = ANY(state_presence) AND $2 = ANY(metros) AND lifecycle_status NOT IN ('duplicate','archived')
GROUP BY organization_type`, [state, m.key]);
const map = Object.fromEntries(byType.map((r) => [r.organization_type, r]));
- grid.push({ metro: m.key, label: m.label, coverage: ORG_TYPE_KEYS.map((t) => ({ type: t, count: (map[t] || {}).n || 0, verified: (map[t] || {}).verified || 0 })) });
+ grid.push({ metro: m.key, label: m.label, coverage: scopedTypes.map((t) => ({ type: t, count: (map[t] || {}).n || 0, verified: (map[t] || {}).verified || 0 })) });
}
const gaps = [];
for (const g of grid) for (const c of g.coverage) if (c.count === 0) gaps.push({ metro: g.metro, type: c.type });
diff --git a/src/pr/migrations/003_coverage_exclude.sql b/src/pr/migrations/003_coverage_exclude.sql
new file mode 100644
index 00000000..d5e98669
--- /dev/null
+++ b/src/pr/migrations/003_coverage_exclude.sql
@@ -0,0 +1,8 @@
+-- Coverage-scope exclusion (Steve, 2026-07-30): banks + credit unions rarely publish
+-- public PR/comms staff and saturate the discovery channels, dragging the coverage
+-- metric without being real PR-outreach targets. Exclude them from the coverage gate,
+-- the dashboard "without PR contact" count, and the coverage-gap report. They remain in
+-- the catalog (verified orgs, searchable) — just out of the coverage DENOMINATOR.
+INSERT INTO pr_settings (key, value) VALUES
+ ('coverage_exclude_types', '["bank","credit_union"]')
+ON CONFLICT (key) DO NOTHING;
diff --git a/src/pr/services/settings.js b/src/pr/services/settings.js
index 975db384..d4b82882 100644
--- a/src/pr/services/settings.js
+++ b/src/pr/services/settings.js
@@ -65,30 +65,35 @@ async function californiaGate() {
const nReviewed = Number(orgQ.n), nComplete = Number(orgQ.complete);
checks.org_completeness = { reviewed: nReviewed, complete: nComplete, pct: nReviewed ? Math.round(nComplete / nReviewed * 100) : 0, pass: nReviewed > 0 && nComplete / nReviewed >= 0.8 };
+ // Coverage-scope exclusion (Steve 2026-07-30): org types that don't publish public
+ // comms staff (banks/credit unions) are out of the coverage DENOMINATOR — they stay
+ // in the catalog, just not counted as gate-relevant outreach targets.
+ const exclude = await get('coverage_exclude_types', ['bank', 'credit_union']);
const hi = await db.one(
`WITH hi AS (SELECT id FROM pr_organizations WHERE 'CA'=ANY(state_presence) AND priority_score >= 60
- AND lifecycle_status NOT IN ('duplicate','archived'))
+ AND lifecycle_status NOT IN ('duplicate','archived') AND organization_type <> ALL($1::text[]))
SELECT count(*) AS n,
count(*) FILTER (WHERE EXISTS (
SELECT 1 FROM pr_people p WHERE p.organization_id = hi.id
AND p.lifecycle_status NOT IN ('duplicate','suppressed','wrong_person','left_company')
AND p.department IN ('communications','marketing','leadership','bd','agency'))) AS with_contact
- FROM hi`, []);
+ FROM hi`, [exclude]);
const nHi = Number(hi.n), nWith = Number(hi.with_contact);
// Strict lens (informational, Cody cycle 2): comms/marketing/agency/bd ONLY — the
// spec's gate criterion explicitly includes leadership, so pass/fail stays on the
- // broader set, but this number keeps "we can email a branch manager" honest.
+ // broader set, but this number keeps "we can email a real comms person" honest.
const commsOnly = await db.one(
`WITH hi AS (SELECT id FROM pr_organizations WHERE 'CA'=ANY(state_presence) AND priority_score >= 60
- AND lifecycle_status NOT IN ('duplicate','archived'))
+ AND lifecycle_status NOT IN ('duplicate','archived') AND organization_type <> ALL($1::text[]))
SELECT count(*) FILTER (WHERE EXISTS (
SELECT 1 FROM pr_people p WHERE p.organization_id = hi.id
AND p.lifecycle_status NOT IN ('duplicate','suppressed','wrong_person','left_company')
AND p.department IN ('communications','marketing','agency','bd'))) AS n
- FROM hi`, []);
+ FROM hi`, [exclude]);
checks.contact_coverage = {
high_priority: nHi, with_contact: nWith, pct: nHi ? Math.round(nWith / nHi * 100) : 0,
pass: nHi > 0 && nWith / nHi >= 0.7,
+ excluded_types: exclude,
comms_only: { with_contact: Number(commsOnly.n), pct: nHi ? Math.round(Number(commsOnly.n) / nHi * 100) : 0 },
};
← 4a1cb2d0 yoloforever: cycle 4 ledger — SHIP-AND-PAUSE; public-channel
·
back to Rentv
·
pr-intelligence: headless tool skips coverage-excluded org t f08f88a2 →