resume-anonymizer
Version:
A flexible NPM package for anonymizing resumes to reduce bias in hiring
206 lines (202 loc) • 8.81 kB
JavaScript
;
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
};
}