[object Object]

← back to Rentv 2026

pr-intelligence: Cody-gate c2 fixes — isLikelyPersonName chokepoint gate in people.create (+manual override), press-contact name/label swap-fix, caller guards, periodic stale-claim recovery (5min), comms-only coverage lens on gate

41924b7499aee6e6f10232d74f54e817b93860d9 · 2026-07-30 19:24:09 -0700 · Steve Abrams

Files touched

Diff

commit 41924b7499aee6e6f10232d74f54e817b93860d9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 19:24:09 2026 -0700

    pr-intelligence: Cody-gate c2 fixes — isLikelyPersonName chokepoint gate in people.create (+manual override), press-contact name/label swap-fix, caller guards, periodic stale-claim recovery (5min), comms-only coverage lens on gate
---
 src/pr/jobs/index.js            |  8 ++++++++
 src/pr/lib/normalize.js         | 31 ++++++++++++++++++++++++++++++-
 src/pr/services/importexport.js |  2 ++
 src/pr/services/people.js       |  8 +++++++-
 src/pr/services/replies.js      |  1 +
 src/pr/services/settings.js     | 17 ++++++++++++++++-
 src/pr/worker.js                |  4 ++++
 test/pr/unit.test.js            |  8 ++++++++
 8 files changed, 76 insertions(+), 3 deletions(-)

diff --git a/src/pr/jobs/index.js b/src/pr/jobs/index.js
index 501b48ae..97ec9728 100644
--- a/src/pr/jobs/index.js
+++ b/src/pr/jobs/index.js
@@ -446,10 +446,18 @@ const handlers = {
         ORDER BY priority_score DESC, id LIMIT $2 OFFSET $3`, [state, limit, offset]);
     let created = 0, scanned = 0;
     const ck = { offset };
+    const { isLikelyPersonName } = require('../lib/normalize');
     for (const org of orgs) {
       const rep = await web.pressContacts(org.press_page_url || org.newsroom_url);
       scanned++;
       for (const c of rep.items) {
+        // Swap-fix (Cody, cycle 2): "Media Contact: <Agency>\nKris Cole" parses with the
+        // label in full_name and the human in exact_title — if the title looks MORE like
+        // a person name than the name does, swap them.
+        if (c.exact_title && isLikelyPersonName(c.exact_title) && c.exact_title.split(/\s+/).length <= 3 &&
+            (!isLikelyPersonName(c.full_name) || /partners|group|inc|llc|communications|agency/i.test(c.full_name))) {
+          const t = c.full_name; c.full_name = c.exact_title; c.exact_title = null; c.note_label = t;
+        }
         const r = await people.create({
           full_name: c.full_name, exact_title: c.exact_title,
           organization_id: org.id, state, metro: (org.metros || [])[0] || null,
diff --git a/src/pr/lib/normalize.js b/src/pr/lib/normalize.js
index 8633c8e4..c447c89a 100644
--- a/src/pr/lib/normalize.js
+++ b/src/pr/lib/normalize.js
@@ -71,6 +71,35 @@ function normalizeLinkedInUrl(url) {
   return `https://www.linkedin.com/${m[1].toLowerCase()}/${m[2].replace(/\/+$/, '')}`;
 }
 
+// Nav-chrome / section-header words that masquerade as Name-Case people on team pages
+// ("Who We Are", "Personal Login", "Leaving Exchange Bank" — all confirmed in prod).
+const COMMON_NONNAME_WORDS = new Set([
+  'who', 'we', 'are', 'our', 'your', 'the', 'and', 'about', 'us', 'meet', 'team',
+  'board', 'directors', 'director', 'management', 'leadership', 'staff', 'people',
+  'login', 'log', 'sign', 'online', 'personal', 'business', 'banking', 'bank',
+  'welcome', 'home', 'contact', 'leaving', 'exchange', 'visit', 'partner', 'partners',
+  'website', 'privacy', 'policy', 'careers', 'news', 'events', 'services', 'search',
+  'menu', 'group', 'company', 'officers', 'executive', 'senior', 'more', 'view', 'all',
+  'of', 'for', 'with', 'from', 'to', 'in', 'at', 'on', 'read', 'learn', 'get', 'our',
+]);
+
+/**
+ * Is this string plausibly a HUMAN name? (Cody gate, cycle 2: nav chrome like
+ * "Who We Are" classified as leadership and polluted the people table.)
+ * Requires 2–4 tokens, each ≥2 chars, no digits/HTML entities, ≤40 chars, and at
+ * least one token that is NOT a common non-name word.
+ */
+function isLikelyPersonName(name) {
+  const s = String(name || '').trim();
+  if (s.length < 4 || s.length > 40) return false;
+  if (/[&<>@\d;#]/.test(s)) return false;                 // entities, emails, digits
+  const tokens = s.split(/\s+/);
+  if (tokens.length < 2 || tokens.length > 4) return false;
+  if (!tokens.every((t) => /^[A-Za-z'’.-]{2,}$/.test(t) || /^[A-Z]\.?$/.test(t))) return false;
+  const hasProper = tokens.some((t) => !COMMON_NONNAME_WORDS.has(t.toLowerCase().replace(/[^a-z]/g, '')));
+  return hasProper;
+}
+
 /** Clamp + trim free text (mirrors server.js clean()). */
 function clean(s, n) { return String(s == null ? '' : s).trim().slice(0, n || 500); }
 
@@ -83,5 +112,5 @@ function normalizePhone(p) {
 
 module.exports = {
   normalizeOrgName, normalizePersonName, splitName, normalizeDomain,
-  normalizeEmail, emailDomain, normalizeLinkedInUrl, clean, normalizePhone,
+  normalizeEmail, emailDomain, normalizeLinkedInUrl, clean, normalizePhone, isLikelyPersonName,
 };
diff --git a/src/pr/services/importexport.js b/src/pr/services/importexport.js
index 14e39066..b4f2a943 100644
--- a/src/pr/services/importexport.js
+++ b/src/pr/services/importexport.js
@@ -125,6 +125,7 @@ async function runImport({ kind, csv, json, columnMap, dry_run = false, filename
         }
         const r = await people.create({ ...rec, organization_id: orgId, import_batch_id: batch.id },
           { evidence: { ...evidenceBase, fields: { organization: 50, exact_title: rec.exact_title ? 50 : 0 } }, actor });
+        if (r.rejected) { report.skipped.push({ rec: rec.full_name, reason: r.rejected }); continue; }
         (r.created ? report.created : report.duplicates).push({ name: rec.full_name, id: r.person.id, matched: r.matched });
       } else if (kind === 'linkedin_urls') {
         const url = normalizeLinkedInUrl(rec.linkedin_url);
@@ -136,6 +137,7 @@ async function runImport({ kind, csv, json, columnMap, dry_run = false, filename
         } else if (rec.full_name) {
           const r = await people.create({ full_name: rec.full_name, linkedin_url: url, linkedin_status: 'found_uncorroborated', import_batch_id: batch.id },
             { evidence: { ...evidenceBase, fields: { linkedin_url: 40 } }, actor });
+          if (r.rejected) { report.skipped.push({ rec: rec.full_name, reason: r.rejected }); continue; }
           (r.created ? report.created : report.duplicates).push({ name: rec.full_name, id: r.person.id });
         } else report.skipped.push({ rec, reason: 'need person_id or full_name' });
       } else {
diff --git a/src/pr/services/people.js b/src/pr/services/people.js
index eb9d61aa..c40b4c00 100644
--- a/src/pr/services/people.js
+++ b/src/pr/services/people.js
@@ -5,7 +5,7 @@
 //  - manual corrections preserve the originally-extracted value (original_values)
 //  - role changes append to role_history rather than silently overwriting
 const db = require('../db');
-const { normalizePersonName, splitName, normalizeEmail, normalizeLinkedInUrl, normalizePhone, clean } = require('../lib/normalize');
+const { normalizePersonName, splitName, normalizeEmail, normalizeLinkedInUrl, normalizePhone, clean, isLikelyPersonName } = require('../lib/normalize');
 const { classifyTitle } = require('../lib/taxonomy');
 const { classifyPersonMatch } = require('../lib/dedupe');
 const { personConfidence, outreachPriority } = require('../lib/scoring');
@@ -34,6 +34,12 @@ async function findMatches(candidate) {
 async function create(data, { evidence, actor } = {}) {
   const full_name = clean(data.full_name, 200);
   if (!full_name) throw new Error('full_name required');
+  // Cody gate (cycle 2): nav-chrome strings ("Who We Are", "Personal Login") reached
+  // the table through adapter-level gaps — validate humanness at the ONE chokepoint.
+  // Manual admin entry may override with allow_nonstandard_name:true (audited).
+  if (!isLikelyPersonName(full_name) && data.allow_nonstandard_name !== true) {
+    return { person: null, created: false, rejected: 'not_a_person_name', full_name };
+  }
   const names = splitName(full_name);
   const cls = classifyTitle(data.exact_title);
   const email = normalizeEmail(data.public_work_email);
diff --git a/src/pr/services/replies.js b/src/pr/services/replies.js
index 7b2bff39..834bf88e 100644
--- a/src/pr/services/replies.js
+++ b/src/pr/services/replies.js
@@ -146,6 +146,7 @@ async function processReferral({ message_id, referred_name, referred_title, refe
     },
     actor,
   });
+  if (!created.person) throw new Error('referred name "' + referred_name + '" does not look like a person name (' + (created.rejected || 'rejected') + ')');
   await outreach.transition(message_id, 'referred', actor, { referred_person_id: created.person.id });
   await db.query(
     `INSERT INTO pr_tasks (title, detail, entity_type, entity_id, due_at, created_by)
diff --git a/src/pr/services/settings.js b/src/pr/services/settings.js
index 9707839f..975db384 100644
--- a/src/pr/services/settings.js
+++ b/src/pr/services/settings.js
@@ -75,7 +75,22 @@ async function californiaGate() {
                 AND p.department IN ('communications','marketing','leadership','bd','agency'))) AS with_contact
        FROM hi`, []);
   const nHi = Number(hi.n), nWith = Number(hi.with_contact);
-  checks.contact_coverage = { high_priority: nHi, with_contact: nWith, pct: nHi ? Math.round(nWith / nHi * 100) : 0, pass: nHi > 0 && nWith / nHi >= 0.7 };
+  // 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.
+  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'))
+     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`, []);
+  checks.contact_coverage = {
+    high_priority: nHi, with_contact: nWith, pct: nHi ? Math.round(nWith / nHi * 100) : 0,
+    pass: nHi > 0 && nWith / nHi >= 0.7,
+    comms_only: { with_contact: Number(commsOnly.n), pct: nHi ? Math.round(Number(commsOnly.n) / nHi * 100) : 0 },
+  };
 
   const flows = await db.one(
     `SELECT count(*) FILTER (WHERE action LIKE 'review.%')      AS review,
diff --git a/src/pr/worker.js b/src/pr/worker.js
index e438d6fc..d0346a92 100644
--- a/src/pr/worker.js
+++ b/src/pr/worker.js
@@ -24,7 +24,11 @@ async function loop() {
   if (!h.ok) { console.error('[pr-worker] database unavailable: ' + (h.reason || '')); process.exit(1); }
   await db.runMigrations({ log: (m) => console.log('[pr-worker] ' + m) });
   await recoverStaleClaims();
+  let lastRecovery = Date.now();
   while (!stopping) {
+    // periodic orphan recovery (Cody, cycle 2): boot-only recovery left a mid-restart
+    // claim stuck 'running' for 71 minutes — sweep every 5 minutes instead.
+    if (Date.now() - lastRecovery > 5 * 60e3) { lastRecovery = Date.now(); try { await recoverStaleClaims(); } catch { /* next tick */ } }
     let job = null;
     try { job = await jobs.claim(); } catch (e) { console.error('[pr-worker] claim error: ' + e.message); }
     if (!job) { await new Promise((r) => setTimeout(r, POLL_MS)); continue; }
diff --git a/test/pr/unit.test.js b/test/pr/unit.test.js
index 77050982..6f3e44b6 100644
--- a/test/pr/unit.test.js
+++ b/test/pr/unit.test.js
@@ -150,3 +150,11 @@ test('CSV parse handles quotes, commas, CRLF; toCSV round-trips', () => {
   const csv = toCSV([{ a: 'x,y', b: 'z' }], ['a', 'b']);
   assert.equal(csv, 'a,b\n"x,y",z');
 });
+
+test('person-name validator kills nav-chrome, keeps real names (Cody gate, cycle 2)', () => {
+  const { isLikelyPersonName } = require('../../src/pr/lib/normalize');
+  for (const junk of ['Who We Are', 'Personal Login', 'Leaving Exchange Bank', 'Meet Our Team', 'Board of Directors', 'Online Banking', 'Directors &#038; Management', 'X', 'A B C D E F'])
+    assert.equal(isLikelyPersonName(junk), false, junk + ' must be rejected');
+  for (const real of ['Kris Cole', 'Sophia Biazus', 'Leeza Hoyt', 'Jane Q. Smith', "Robert O'Brien", 'Tod Nasser', 'Glenn LaFollette'])
+    assert.equal(isLikelyPersonName(real), true, real + ' must pass');
+});

← 284c46a9 pr-intelligence: press-contact extractor — RSS-first release  ·  back to Rentv 2026  ·  yoloforever: cycle 2 ledger (SHIP 5/5); local-models directi cf59aa53 →