UNPKG

resume-anonymizer

Version:

A flexible NPM package for anonymizing resumes to reduce bias in hiring

206 lines (202 loc) 8.81 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.analyzeForAnonymization = analyzeForAnonymization; exports.applyAIAnonymization = applyAIAnonymization; const openai_1 = require("@ai-sdk/openai"); const ai_1 = require("ai"); const zod_1 = require("zod"); const pii_detector_1 = require("./utils/pii-detector"); // Schema for anonymization analysis const anonymizationSchema = zod_1.z.object({ fieldsToAnonymize: zod_1.z.array(zod_1.z.object({ path: zod_1.z.string().describe('The path to the field in the object (e.g., "basics.name" or "work[0].company")'), currentValue: zod_1.z.string().describe('The current value of the field'), reason: zod_1.z.string().describe('Why this field should be anonymized'), suggestedReplacement: zod_1.z.string().describe('Suggested anonymous replacement value'), category: zod_1.z.enum(['name', 'company', 'education', 'location', 'contact', 'other']).describe('Category of the field') })).describe('List of fields that should be anonymized'), textFieldsWithPII: zod_1.z.array(zod_1.z.object({ path: zod_1.z.string().describe('Path to the text field'), detectedPII: zod_1.z.array(zod_1.z.object({ type: zod_1.z.enum(['email', 'phone', 'url', 'name', 'company', 'location']), value: zod_1.z.string(), replacement: zod_1.z.string() })).describe('PII detected in this text field') })).describe('Text fields containing PII that needs to be removed'), rewordingSuggestions: zod_1.z.array(zod_1.z.object({ path: zod_1.z.string().describe('Path to the field that could be reworded'), originalText: zod_1.z.string().describe('Original text'), rewordedText: zod_1.z.string().describe('Reworded version that maintains meaning but removes identifying patterns'), reason: zod_1.z.string().describe('Why rewording helps anonymization') })).describe('Suggestions for rewording text to remove identifying patterns') }); async function analyzeForAnonymization(data, options = {}) { const openai = (0, openai_1.createOpenAI)({ apiKey: options.apiKey || process.env.OPENAI_API_KEY, }); const model = options.model || 'gpt-4o-mini'; const rewordingLevel = options.rewordingLevel || 'medium'; const prompt = `Analyze this data object for anonymization. Identify: 1. Fields containing personal/identifying information that should be anonymized 2. Text fields containing embedded PII (emails, phones, etc.) 3. Text that could be reworded to remove identifying patterns while preserving meaning Data to analyze: ${JSON.stringify(data, null, 2)} Rewording level: ${rewordingLevel} - none: Only identify fields, don't suggest rewording - light: Minor rewording to remove obvious identifiers - medium: Moderate rewording to obscure patterns - heavy: Significant rewording to maximize anonymity Focus on maintaining data utility while removing identifying information.`; try { const { object } = await (0, ai_1.generateObject)({ model: openai(model), schema: anonymizationSchema, prompt, }); return object; } catch { // AI analysis failed, fallback to basic PII detection return fallbackAnalysis(data); } } function fallbackAnalysis(data) { const fieldsToAnonymize = []; const textFieldsWithPII = []; function analyzeRecursive(obj, path = '') { if (!obj || typeof obj !== 'object') return; for (const [key, value] of Object.entries(obj)) { const currentPath = path ? `${path}.${key}` : key; if (typeof value === 'string') { // Check for PII const pii = (0, pii_detector_1.detectPII)(value); if (pii.emails.length > 0 || pii.phones.length > 0 || pii.urls.length > 0) { const detectedPII = []; pii.emails.forEach(email => { detectedPII.push({ type: 'email', value: email, replacement: '[email removed]' }); }); pii.phones.forEach(phone => { detectedPII.push({ type: 'phone', value: phone, replacement: '[phone removed]' }); }); pii.urls.forEach(url => { detectedPII.push({ type: 'url', value: url, replacement: '[link removed]' }); }); if (detectedPII.length > 0) { textFieldsWithPII.push({ path: currentPath, detectedPII }); } } // Check field names for common PII fields const lowerKey = key.toLowerCase(); if (lowerKey.includes('name') || lowerKey.includes('email') || lowerKey.includes('phone') || lowerKey.includes('address')) { fieldsToAnonymize.push({ path: currentPath, currentValue: value, reason: 'Field name suggests personal information', suggestedReplacement: `[${key} removed]`, category: 'other' }); } } else if (Array.isArray(value)) { value.forEach((item, index) => { analyzeRecursive(item, `${currentPath}[${index}]`); }); } else if (typeof value === 'object') { analyzeRecursive(value, currentPath); } } } analyzeRecursive(data); return { fieldsToAnonymize, textFieldsWithPII, rewordingSuggestions: [] }; } async function applyAIAnonymization(data, analysis, _options = {}) { const result = JSON.parse(JSON.stringify(data)); // Deep clone const changes = {}; // Apply field anonymization for (const field of analysis.fieldsToAnonymize) { const pathParts = field.path.split(/[.[\]]+/).filter(Boolean); let current = result; let parent = null; let lastKey = ''; // Navigate to the field for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (!current[part]) break; parent = current; current = current[part]; lastKey = pathParts[pathParts.length - 1]; } if (parent && lastKey && parent[lastKey] !== undefined) { changes[field.path] = { original: parent[lastKey], replacement: field.suggestedReplacement }; parent[lastKey] = field.suggestedReplacement; } } // Apply PII removal from text fields for (const textField of analysis.textFieldsWithPII) { const pathParts = textField.path.split(/[.[\]]+/).filter(Boolean); let current = result; let parent = null; let lastKey = ''; for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (!current[part]) break; parent = current; current = current[part]; lastKey = pathParts[pathParts.length - 1]; } if (parent && lastKey && typeof parent[lastKey] === 'string') { let text = parent[lastKey]; for (const pii of textField.detectedPII) { text = text.replace(pii.value, pii.replacement); } if (text !== parent[lastKey]) { changes[textField.path] = { original: parent[lastKey], replacement: text }; parent[lastKey] = text; } } } // Apply rewording suggestions for (const suggestion of analysis.rewordingSuggestions) { const pathParts = suggestion.path.split(/[.[\]]+/).filter(Boolean); let current = result; let parent = null; let lastKey = ''; for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (!current[part]) break; parent = current; current = current[part]; lastKey = pathParts[pathParts.length - 1]; } if (parent && lastKey && parent[lastKey] === suggestion.originalText) { changes[suggestion.path] = { original: suggestion.originalText, replacement: suggestion.rewordedText, reason: suggestion.reason }; parent[lastKey] = suggestion.rewordedText; } } return { anonymizedData: result, changes, analysis }; }