-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreclassify-workers.js
More file actions
239 lines (212 loc) · 9.11 KB
/
Copy pathreclassify-workers.js
File metadata and controls
239 lines (212 loc) · 9.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#!/usr/bin/env node
/**
* One-time batch reclassifier for data/workers.json
*
* For each worker, asks Claude Haiku to determine:
* - primary category (must be one of the 12 valid trades)
* - secondary categories (for multi-trade businesses)
*
* Runs in batches of 20 to keep token use low.
* Writes a backup to data/workers.backup.json before overwriting.
*
* Usage:
* ANTHROPIC_API_KEY=xxx node scripts/reclassify-workers.js # full run
* ANTHROPIC_API_KEY=xxx node scripts/reclassify-workers.js --dry # preview only, no write
* ANTHROPIC_API_KEY=xxx node scripts/reclassify-workers.js --limit 50 # process first 50 only
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const VALID_CATS = [
'بلومبي', 'طريسيان', 'صباغة', 'نجارة', 'بناء', 'نقاوة',
'حدادة', 'ديكور', 'نقل', 'كلامبيستري', 'خياطة', 'حراسة'
];
const BATCH_SIZE = 20;
const SLEEP_MS = 600; // pace requests so we never trip rate limits
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry');
const limitIdx = args.indexOf('--limit');
const LIMIT = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
const DATA_PATH = path.join(__dirname, '..', 'data', 'workers.json');
const BACKUP_PATH = path.join(__dirname, '..', 'data', `workers.backup.${Date.now()}.json`);
const REPORT_PATH = path.join(__dirname, '..', 'data', `reclassify-report.${Date.now()}.json`);
const KEY = process.env.ANTHROPIC_API_KEY;
if (!KEY) {
console.error('FATAL: ANTHROPIC_API_KEY missing. Set it in .env or env var.');
process.exit(1);
}
const ANTHROPIC_MODEL = 'claude-haiku-4-5';
const ANTHROPIC_VERSION = '2023-06-01';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function chunks(arr, n) {
const out = [];
for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
return out;
}
async function classifyBatch(batch) {
const prompt = `نتا غادي تصنف ليا ${batch.length} ديال المعلمين. لكل واحد قول ليا:
- "primary": نوع الخدمة الكبير (واحد بصاح من اللائحة)
- "secondary": ليستة فيها أنواع زايدة إيلا كان المعلم كيدير كتر من خدمة (خليها [] إيلا غير وحدة)
اللائحة المسموحة (ما تخرجش منها): ${VALID_CATS.join('، ')}
قواعد خاصك تتبعها:
- "Plomberie Electricité" → primary هو اللي بان كتر فالسمية، الآخر دير فsecondary
- "Société de nettoyage et peinture" → primary حسب اللي بان كتر فالسمية، الآخر فsecondary
- "Design / Décoration" فالسمية → غالبا ديكور، إلا إيلا الوصف قال شي حاجة أخرى (مثلا Floor Design = كلامبيستري حيت عاد بلاط)
- شبابك ألومنيوم ولا حديد → حدادة. شبابك ديال الخشب → نجارة
- "Déménagement" → نقل (ماشي نقاوة، حيت كلمة "ménage" دخلات بالغلط)
- "Sécurité" → حراسة. "Nettoyage/Ménage" → نقاوة
- "Tableau électrique" → طريسيان
- "Faux plafond / Gypse / Plâtre" → ديكور
- "Carrelage / Zellige / Faïence / Marbre" → كلامبيستري
- "Serrurerie / Ferronnerie / Inox" → حدادة
- "Plomberie / Sanitaire / Chauffe-eau" → بلومبي
رجع JSON array بصاح (بلا شرح، بلا markdown). كل واحد فيه: {"i": index, "primary": "...", "secondary": [...]}.
المعلمين:
${batch.map((w, i) => `${i}: name="${(w.name || '').slice(0, 100)}" desc="${(w.description || '').slice(0, 150)}" tags=${JSON.stringify(w.tags || [])}`).join('\n')}`;
const r = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': KEY,
'anthropic-version': ANTHROPIC_VERSION,
'content-type': 'application/json',
},
body: JSON.stringify({
model: ANTHROPIC_MODEL,
max_tokens: 2000,
temperature: 0,
system: 'Respond with valid JSON only. No prose, no markdown fences, no commentary.',
messages: [{ role: 'user', content: prompt }],
}),
});
if (!r.ok) {
const errBody = await r.text().catch(() => '');
throw new Error(`Anthropic API ${r.status}: ${errBody.slice(0, 300)}`);
}
const data = await r.json();
const text = (data.content || []).filter(b => b && b.type === 'text').map(b => b.text).join('').trim();
// Strip code fences if model still wrapped them despite system instruction
const clean = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
let parsed;
try {
parsed = JSON.parse(clean);
} catch (e) {
console.error('Bad JSON from model:', clean.slice(0, 300));
throw e;
}
if (!Array.isArray(parsed)) throw new Error('Model did not return an array');
return parsed;
}
function validateClass(c) {
if (!c || typeof c !== 'object') return null;
const primary = VALID_CATS.includes(c.primary) ? c.primary : null;
if (!primary) return null;
const secondary = Array.isArray(c.secondary)
? c.secondary.filter(x => VALID_CATS.includes(x) && x !== primary)
: [];
return { primary, secondary };
}
(async () => {
console.log('Reading', DATA_PATH);
const original = fs.readFileSync(DATA_PATH, 'utf8');
const workers = JSON.parse(original);
console.log('Loaded', workers.length, 'workers');
const target = LIMIT ? workers.slice(0, LIMIT) : workers;
console.log(`Will classify ${target.length} workers in batches of ${BATCH_SIZE}`);
console.log(DRY_RUN ? '** DRY RUN — no writes **' : '** WILL OVERWRITE workers.json **');
const batches = chunks(target, BATCH_SIZE);
const changes = [];
const addedSecondary = [];
let processed = 0;
let failed = 0;
for (let bi = 0; bi < batches.length; bi++) {
const batch = batches[bi];
process.stdout.write(`Batch ${bi + 1}/${batches.length} (${processed}/${target.length})... `);
let parsed = null;
// Retry up to 3 times with exponential backoff for transient 4xx/5xx
for (let attempt = 1; attempt <= 3; attempt++) {
try {
parsed = await classifyBatch(batch);
break;
} catch (e) {
const wait = SLEEP_MS * Math.pow(3, attempt);
process.stdout.write(`(attempt ${attempt} failed: ${e.message.slice(0, 60)}, waiting ${wait}ms) `);
await sleep(wait);
}
}
if (!parsed) {
console.log('FAILED after 3 attempts.');
failed += batch.length;
continue;
}
for (const item of parsed) {
const i = typeof item.i === 'number' ? item.i : parseInt(item.i, 10);
if (!Number.isInteger(i) || i < 0 || i >= batch.length) continue;
const worker = batch[i];
const cls = validateClass(item);
if (!cls) continue;
const oldCat = worker.category;
const oldSec = JSON.stringify(worker.secondary_categories || []);
const newSec = JSON.stringify(cls.secondary);
if (cls.primary !== oldCat) {
changes.push({
name: worker.name,
city: worker.city,
old: oldCat,
new: cls.primary,
secondary: cls.secondary
});
worker.category = cls.primary;
}
if (cls.secondary.length && newSec !== oldSec) {
worker.secondary_categories = cls.secondary;
addedSecondary.push({
name: worker.name,
primary: cls.primary,
secondary: cls.secondary
});
} else if (cls.secondary.length === 0 && worker.secondary_categories) {
delete worker.secondary_categories;
}
}
processed += batch.length;
console.log('done.');
await sleep(SLEEP_MS);
}
console.log('\n=== REPORT ===');
console.log('Processed:', processed);
console.log('Failed:', failed);
console.log('Primary category changes:', changes.length);
console.log('Workers given secondary categories:', addedSecondary.length);
console.log('\nFirst 30 primary changes:');
changes.slice(0, 30).forEach(c => {
console.log(` [${c.old} → ${c.new}] ${c.name} (${c.city})${c.secondary.length ? ` +secondary: ${c.secondary.join(',')}` : ''}`);
});
console.log('\nFirst 15 multi-trade additions:');
addedSecondary.slice(0, 15).forEach(a => {
console.log(` [${a.primary} + ${a.secondary.join(',')}] ${a.name}`);
});
// Per-category counts
const counts = {};
workers.forEach(w => { counts[w.category] = (counts[w.category] || 0) + 1; });
console.log('\nPost-reclassification counts:');
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([c, n]) => console.log(` ${c}: ${n}`));
fs.writeFileSync(REPORT_PATH, JSON.stringify({
processed, failed,
primaryChanges: changes,
secondaryAdditions: addedSecondary,
finalCounts: counts
}, null, 2));
console.log('\nFull report:', REPORT_PATH);
if (DRY_RUN) {
console.log('\nDRY RUN — workers.json NOT modified.');
return;
}
fs.writeFileSync(BACKUP_PATH, original);
console.log('Backup:', BACKUP_PATH);
fs.writeFileSync(DATA_PATH, JSON.stringify(workers, null, 2));
console.log('Updated:', DATA_PATH);
})().catch(e => {
console.error('FATAL:', e);
process.exit(1);
});