← 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
M src/pr/jobs/index.jsM src/pr/lib/normalize.jsM src/pr/services/importexport.jsM src/pr/services/people.jsM src/pr/services/replies.jsM src/pr/services/settings.jsM src/pr/worker.jsM test/pr/unit.test.js
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 & 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 →