wulf-pulse/lib/services/pax8-company-matcher.ts

269 lines
10 KiB
TypeScript

/**
* PAX8 company <-> Autotask company fuzzy matcher (PAX8-10, PAX8-11).
*
* Ported from lib/services/device-link-reconciler.ts's cascading
* findBy-x / pickBestCandidate / applyLink / recordConflict shape, adapted
* from exact-match cascades to a single trigram-similarity score.
*
* Unlike the reconciler, this does NOT run as a standalone cron this phase —
* it is invoked from Pax8SyncService.fullSync() (Plan 04), once per sync run,
* against the eligible (unresolved) subset of pax8_companies.
*
* Match policy (12-CONTEXT.md / 12-RESEARCH.md locked decisions):
* D-01: auto-link only at similarity >= AUTO_LINK_THRESHOLD, single
* candidate, no near-tie.
* D-02: a second candidate within TIE_MARGIN of the top score forces
* review even at score 1.0 — never silently tie-break.
* D-03: zero candidates above CANDIDATE_FLOOR still creates a review row
* with an empty candidate_company_ids array — never silently dropped.
* D-04: ambiguous review rows carry the top 3 candidates.
* D-05 / SC#4: only unresolved rows are (re)scored every sync; a
* human-resolved match (resolved review row, or match_method='manual')
* is never overwritten.
*/
import postgresClient from '@/lib/services/postgres-client';
// D-01: conservative, initial/tunable auto-link floor. Validated live against
// this project's real 118 pax8_companies vs 242 active companies — every
// genuine match scored 1.00, the highest non-match observed was 0.70, so 0.90
// sits comfortably in the empty gap between the two clusters.
export const AUTO_LINK_THRESHOLD = 0.9;
// D-02: a second candidate within this margin of the top score is treated as
// an unresolved tie and forced to review, even if the top score alone would
// clear AUTO_LINK_THRESHOLD.
export const TIE_MARGIN = 0.05;
// Floor below which a candidate isn't even considered — keeps the review
// candidate list small and relevant.
export const CANDIDATE_FLOOR = 0.3;
interface CompanyCandidate {
autotask_company_id: number;
score: number; // pg_trgm similarity(), 0.0-1.0
}
interface EligibleCompany {
id: string;
name: string;
}
export interface Pax8CompanyMatchResult {
scanned: number;
autoLinked: number;
flaggedAmbiguous: number;
flaggedNoCandidate: number;
durationMs: number;
}
type Decision =
| { kind: 'auto'; match: CompanyCandidate }
| { kind: 'review'; top3: CompanyCandidate[] };
/**
* Fuzzy candidate lookup against active Autotask companies. The PAX8 name is
* always bound as $1 — never string-interpolated (Security Domain / T-12-01).
* pg_trgm's similarity() is already case-insensitive (Pitfall 4) so no
* LOWER() wrapping is added; TRIM() is applied to the input purely for
* cleaner review-table display, not for scoring purposes.
*/
async function findCandidates(pax8Name: string): Promise<CompanyCandidate[]> {
const res = await postgresClient.query<{ id: string; score: string }>(
`SELECT id::text, similarity($1, company_name)::text AS score
FROM companies
WHERE is_active = true
AND similarity($1, company_name) > ${CANDIDATE_FLOOR}
ORDER BY score DESC
LIMIT 5`,
[pax8Name]
);
return res.rows.map((r) => ({
autotask_company_id: Number(r.id),
score: Number(r.score),
}));
}
/**
* D-01..D-04 decision logic: exactly one auto-link path (unambiguous,
* high-confidence), everything else routed to review.
*/
function decide(candidates: CompanyCandidate[]): Decision {
if (candidates.length === 0) return { kind: 'review', top3: [] }; // D-03
const [best, second] = candidates;
const tie = second !== undefined && best.score - second.score < TIE_MARGIN;
if (best.score >= AUTO_LINK_THRESHOLD && !tie) {
return { kind: 'auto', match: best };
}
return { kind: 'review', top3: candidates.slice(0, 3) }; // D-02/D-04
}
/**
* Auto-link a PAX8 company to its matched Autotask company. Guarded so a
* human-resolved match is never overwritten (D-05 / SC#4): the UPDATE only
* fires when match_method isn't already 'manual' AND there is no review row
* for this company that has already been human-resolved.
*
* Any open (never-human-touched) review row for this company is closed —
* a now-confident automated match supersedes a stale open flag.
*/
async function applyLink(
pax8CompanyId: string,
autotaskCompanyId: number,
score: number
): Promise<void> {
await postgresClient.query(
`UPDATE pax8_companies
SET autotask_company_id = $2,
match_confidence = $3,
match_method = 'pg_trgm',
matched_at = NOW()
WHERE id = $1
AND match_method IS DISTINCT FROM 'manual'
AND NOT EXISTS (
SELECT 1 FROM pax8_company_match_review r
WHERE r.pax8_company_id = pax8_companies.id
AND r.resolved_at IS NOT NULL
)`,
[pax8CompanyId, autotaskCompanyId, score.toFixed(3)]
);
await postgresClient.query(
`DELETE FROM pax8_company_match_review
WHERE pax8_company_id = $1
AND resolved_at IS NULL`,
[pax8CompanyId]
);
}
/**
* Flag a PAX8 company for manual review, carrying its top-3 candidates
* (D-04) or an empty array when there were none above CANDIDATE_FLOOR
* (D-03). Upserts against the "one open review per company" partial unique
* index (uq_pax8_company_match_review_open).
*
* If this company previously held a confident pg_trgm auto-match that is now
* ambiguous or below-threshold, the stale match columns are cleared
* conservatively — but a 'manual' match is never cleared this way.
*/
async function recordConflict(
pax8CompanyId: string,
top3: CompanyCandidate[]
): Promise<void> {
await postgresClient.query(
`UPDATE pax8_companies
SET autotask_company_id = NULL,
match_confidence = NULL,
match_method = NULL,
matched_at = NULL
WHERE id = $1
AND match_method = 'pg_trgm'`,
[pax8CompanyId]
);
const candidateIds = top3.map((c) => c.autotask_company_id);
const confidences = top3.map((c) => c.score.toFixed(3));
await postgresClient.query(
`INSERT INTO pax8_company_match_review
(pax8_company_id, candidate_company_ids, match_confidences)
VALUES ($1, $2::bigint[], $3::text[])
ON CONFLICT (pax8_company_id) WHERE resolved_at IS NULL
DO UPDATE SET candidate_company_ids = EXCLUDED.candidate_company_ids,
match_confidences = EXCLUDED.match_confidences,
detected_at = NOW()`,
[pax8CompanyId, candidateIds, confidences]
);
}
/**
* Run one matching pass over the re-scoring-eligible subset of
* pax8_companies (D-05: excludes manually-resolved rows and rows with a
* human-resolved review). Idempotent — safe to run on every sync.
*/
export async function matchPax8Companies(opts?: {
limit?: number;
dryRun?: boolean;
}): Promise<Pax8CompanyMatchResult> {
const startedAt = Date.now();
// WR-02 default raised well above any near-term PAX8/Autotask company
// count (validated at 118 companies; generous headroom for growth) and
// the eligibility ORDER BY below rotates fairness so accumulating
// review-flagged rows can no longer permanently starve alphabetically
// later companies even if this limit is ever hit.
const limit = opts?.limit ?? 10000;
const dryRun = opts?.dryRun ?? false;
const result: Pax8CompanyMatchResult = {
scanned: 0,
autoLinked: 0,
flaggedAmbiguous: 0,
flaggedNoCandidate: 0,
durationMs: 0,
};
try {
// WR-02: ORDER BY name is a static cursor — once flagged-for-review
// companies accumulate (their matched_at stays NULL indefinitely; see
// recordConflict), they keep re-occupying the same slots in every run's
// top-N by name, and once eligible rows exceed `limit`, everything
// sorting alphabetically after that point is starved. Ordering instead
// by "last time this row was actually considered" (the open review's
// detected_at, falling back to matched_at, falling back to the epoch
// for never-yet-scanned rows) rotates fairness: least-recently-attempted
// rows always sort first, so a permanently-open review row sinks behind
// any row that hasn't been reconsidered as recently.
const eligible = await postgresClient.query<EligibleCompany>(
`SELECT c.id::text, c.name
FROM pax8_companies c
LEFT JOIN pax8_company_match_review r
ON r.pax8_company_id = c.id AND r.resolved_at IS NULL
WHERE c.is_deleted = false
AND c.match_method IS DISTINCT FROM 'manual'
AND NOT EXISTS (
SELECT 1 FROM pax8_company_match_review r2
WHERE r2.pax8_company_id = c.id
AND r2.resolved_at IS NOT NULL
)
ORDER BY COALESCE(r.detected_at, c.matched_at, 'epoch'::timestamptz) ASC
LIMIT $1`,
[limit]
);
for (const company of eligible.rows) {
result.scanned += 1;
const candidates = await findCandidates(company.name.trim());
const decision = decide(candidates);
if (decision.kind === 'auto') {
if (!dryRun) {
await applyLink(company.id, decision.match.autotask_company_id, decision.match.score);
}
result.autoLinked += 1;
} else if (decision.top3.length === 0) {
if (!dryRun) {
await recordConflict(company.id, []);
}
result.flaggedNoCandidate += 1;
} else {
if (!dryRun) {
await recordConflict(company.id, decision.top3);
}
result.flaggedAmbiguous += 1;
}
}
result.durationMs = Date.now() - startedAt;
console.log(
`[Pax8Match] scanned=${result.scanned} autoLinked=${result.autoLinked} ` +
`flaggedAmbiguous=${result.flaggedAmbiguous} flaggedNoCandidate=${result.flaggedNoCandidate} ` +
`durationMs=${result.durationMs}`
);
return result;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[Pax8Match] failed:', msg);
result.durationMs = Date.now() - startedAt;
throw err;
}
}