← back to Professional Directory

scripts/dedupe.js

115 lines

#!/usr/bin/env node
/**
 * Dedupe + confidence scoring — Stage 8.
 *
 * Runs matchers in priority and writes each professional's final
 * source_confidence_score, plus collapses duplicate organizations.
 *
 * Confidence rubric:
 *   0.85+  NPI present AND license matched (MBC or DCA) AND has primary location
 *   0.75   NPI + license OR  NPI + hospital-affiliation match
 *   0.50   NPI only (single-source)
 *   0.30   No NPI, name-only match
 */
const { pool, query } = require('../agents/shared/db');

async function recomputeProfessionalScores() {
  console.log('[dedupe] recomputing professional confidence scores…');
  await query(`
    UPDATE professionals p SET source_confidence_score = sub.score, updated_at = NOW()
    FROM (
      SELECT
        p.id,
        CASE
          WHEN p.npi_number IS NOT NULL
               AND p.license_number IS NOT NULL
               AND p.license_status IS NOT NULL
               AND EXISTS(SELECT 1 FROM professional_locations pl WHERE pl.professional_id = p.id)
            THEN 0.92
          WHEN p.npi_number IS NOT NULL AND p.license_number IS NOT NULL
            THEN 0.80
          WHEN p.npi_number IS NOT NULL AND EXISTS(
                  SELECT 1 FROM professional_locations pl
                   WHERE pl.professional_id = p.id AND pl.organization_id IS NOT NULL)
            THEN 0.70
          WHEN p.npi_number IS NOT NULL
            THEN 0.55
          ELSE 0.30
        END AS score
      FROM professionals p
    ) sub
    WHERE p.id = sub.id
  `, []);

  const r = await query(`
    SELECT
      ROUND(AVG(source_confidence_score)::numeric, 2) AS avg_score,
      COUNT(*) FILTER (WHERE source_confidence_score >= 0.85) AS high,
      COUNT(*) FILTER (WHERE source_confidence_score >= 0.50 AND source_confidence_score < 0.85) AS medium,
      COUNT(*) FILTER (WHERE source_confidence_score <  0.50) AS low
    FROM professionals
  `, []);
  console.log('[dedupe] confidence buckets →', r.rows[0]);
}

async function dedupeOrgsByNpi() {
  console.log('[dedupe] collapsing duplicate organizations by NPI…');
  // Already enforced by UNIQUE(npi_number); just safety check counts.
  const r = await query(`
    SELECT npi_number, COUNT(*) AS n
    FROM organizations
    WHERE npi_number IS NOT NULL
    GROUP BY npi_number HAVING COUNT(*) > 1
  `, []);
  console.log(`[dedupe] orgs with duplicate NPI: ${r.rowCount}`);
}

async function dedupeOrgsByCcnHcai() {
  console.log('[dedupe] merging orgs that share an HCAI ID or CMS CCN…');
  // Pick canonical row (lowest id), reassign foreign keys, delete the others.
  for (const col of ['hcai_id', 'cdph_license']) {
    const dupes = await query(`
      SELECT ${col} AS key, MIN(id) AS keep_id, ARRAY_AGG(id) AS all_ids
      FROM organizations
      WHERE ${col} IS NOT NULL
      GROUP BY ${col} HAVING COUNT(*) > 1
    `, []);
    for (const dupe of dupes.rows) {
      const drop = dupe.all_ids.filter(x => x !== dupe.keep_id);
      if (drop.length === 0) continue;
      await query(`UPDATE professional_locations SET organization_id = $1 WHERE organization_id = ANY($2)`,
                  [dupe.keep_id, drop]);
      await query(`UPDATE phones                SET organization_id = $1 WHERE organization_id = ANY($2)`,
                  [dupe.keep_id, drop]);
      await query(`UPDATE emails                SET organization_id = $1 WHERE organization_id = ANY($2)`,
                  [dupe.keep_id, drop]);
      await query(`DELETE FROM organizations WHERE id = ANY($1)`, [drop]);
      console.log(`[dedupe] ${col}=${dupe.key} kept=${dupe.keep_id} dropped=${drop.length}`);
    }
  }
}

async function tagPrivatePractice() {
  console.log('[dedupe] tagging single-doctor orgs as private_practice…');
  await query(`
    UPDATE organizations o SET type = 'private_practice', updated_at = NOW()
    WHERE o.type = 'medical_group'
      AND o.id IN (
        SELECT organization_id FROM professional_locations
         WHERE organization_id IS NOT NULL
         GROUP BY organization_id
        HAVING COUNT(DISTINCT professional_id) = 1
      )
  `, []);
}

async function main() {
  await dedupeOrgsByCcnHcai();
  await dedupeOrgsByNpi();
  await tagPrivatePractice();
  await recomputeProfessionalScores();
  await pool.end();
}

main().catch((e) => { console.error('[dedupe] fatal:', e); process.exit(1); });