[object Object]

← 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

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 →