• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2585

02 Feb 2026 12:27PM UTC coverage: 85.462% (-0.1%) from 85.583%
#2585

push

edwh
Merge remote-tracking branch 'origin/master' into feature/incoming-email-migration

25583 of 29935 relevant lines covered (85.46%)

30.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

9.94
/include/misc/Pollinations.php
1
<?php
2

3
namespace Freegle\Iznik;
4

5
/**
6
 * Helper class for fetching AI-generated images from Pollinations.ai.
7
 * Tracks image hashes to detect rate-limiting (same image returned for different prompts).
8
 * Uses both in-memory and file-based caching for persistence across processes.
9
 */
10
class Pollinations {
11
    # Track hashes we've seen in this process, keyed by hash => prompt.
12
    private static $seenHashes = [];
13

14
    /**
15
     * Canonical job categories mapped to their iconic object for image generation.
16
     * Each key is a canonical job title, value is the object to illustrate.
17
     */
18
    const CANONICAL_JOBS = [
19
        'Accountant' => 'calculator',
20
        'Account Manager' => 'briefcase',
21
        'Activities Coordinator' => 'clipboard',
22
        'Administrator' => 'filing cabinet',
23
        'Architect' => 'blueprint',
24
        'Area Manager' => 'map with pins',
25
        'Assistant Manager' => 'name badge',
26
        'Bartender' => 'cocktail shaker',
27
        'Bid Manager' => 'sealed envelope',
28
        'Bookkeeper' => 'ledger book',
29
        'Branch Manager' => 'desk nameplate',
30
        'Bricklayer' => 'brick trowel',
31
        'Building Inspector' => 'spirit level',
32
        'Building Surveyor' => 'theodolite',
33
        'Bus Driver' => 'steering wheel',
34
        'Business Analyst' => 'flowchart diagram',
35
        'Business Development Manager' => 'handshake icon',
36
        'Buyer' => 'purchase order',
37
        'CAD Technician' => 'technical drawing',
38
        'Care Assistant' => 'stethoscope',
39
        'Care Coordinator' => 'care plan folder',
40
        'Care Worker' => 'medical gloves',
41
        'Carpenter' => 'wood plane',
42
        'Cashier' => 'cash register',
43
        'Catering Assistant' => 'serving tray',
44
        'Chef' => 'chef hat',
45
        'Cleaner' => 'mop and bucket',
46
        'Clinical Assessor' => 'medical clipboard',
47
        'CNC Machinist' => 'CNC milling machine',
48
        'Communications Engineer' => 'satellite dish',
49
        'Compliance Officer' => 'checklist on clipboard',
50
        'Construction Manager' => 'hard hat',
51
        'Contracts Manager' => 'signed contract',
52
        'Cook' => 'saucepan',
53
        'Counsellor' => 'comfortable armchair',
54
        'Credit Controller' => 'invoice stamp',
55
        'Customer Service Advisor' => 'headset',
56
        'Data Analyst' => 'bar chart',
57
        'Data Architect' => 'database server',
58
        'Data Engineer' => 'data pipeline diagram',
59
        'Delivery Driver' => 'delivery van',
60
        'Dental Nurse' => 'dental mirror',
61
        'Deputy Manager' => 'deputy badge',
62
        'Design Engineer' => 'engineering compass',
63
        'Design Manager' => 'design palette',
64
        'Digital Marketing Executive' => 'computer screen with analytics',
65
        'Document Controller' => 'document folder',
66
        'Door Canvasser' => 'clipboard with petition',
67
        'Ecologist' => 'binoculars',
68
        'Electrical Engineer' => 'circuit board',
69
        'Electrician' => 'wire strippers',
70
        'Embedded Software Engineer' => 'microchip',
71
        'Engineering Apprentice' => 'spanner set',
72
        'Estimator' => 'measuring tape and calculator',
73
        'Factory Operative' => 'conveyor belt',
74
        'Female Support Worker' => 'support badge',
75
        'Field Sales Representative' => 'sales sample case',
76
        'Field Service Engineer' => 'tool bag',
77
        'Finance Assistant' => 'spreadsheet printout',
78
        'Finance Business Partner' => 'financial report',
79
        'Finance Manager' => 'balance sheet',
80
        'Financial Controller' => 'accounting ledger',
81
        'Forklift Driver' => 'forklift',
82
        'Fundraiser' => 'collection tin',
83
        'Gas Engineer' => 'gas boiler',
84
        'General Manager' => 'office desk',
85
        'Groundworker' => 'shovel',
86
        'Head of Finance' => 'financial dashboard',
87
        'Head of Marketing' => 'megaphone',
88
        'Healthcare Assistant' => 'blood pressure monitor',
89
        'HGV Class 1 Driver' => 'articulated lorry',
90
        'HGV Class 2 Driver' => 'rigid lorry',
91
        'HGV Technician' => 'truck engine',
92
        'Home Manager' => 'care home building',
93
        'Housekeeper' => 'vacuum cleaner',
94
        'HR Advisor' => 'employee handbook',
95
        'HR Business Partner' => 'HR policy document',
96
        'Installer' => 'power drill',
97
        'IT Support' => 'computer keyboard',
98
        'IT Apprentice' => 'laptop computer',
99
        'Kitchen Assistant' => 'kitchen knife set',
100
        'Kitchen Designer' => 'kitchen floor plan',
101
        'Labourer' => 'wheelbarrow',
102
        'Lecturer' => 'lectern',
103
        'Legal Secretary' => 'legal documents',
104
        'Lifeguard' => 'lifeguard float',
105
        'Machine Learning Engineer' => 'neural network diagram',
106
        'Machine Operator' => 'industrial machine',
107
        'Maintenance Electrician' => 'multimeter',
108
        'Maintenance Engineer' => 'wrench and gears',
109
        'Maintenance Manager' => 'maintenance toolkit',
110
        'Maintenance Technician' => 'toolbox',
111
        'Management Accountant' => 'financial spreadsheet',
112
        'Manufacturing Engineer' => 'factory robot arm',
113
        'Marketing Manager' => 'marketing campaign board',
114
        'Maths Teacher' => 'protractor and compass',
115
        'Mechanical Design Engineer' => 'mechanical gear drawing',
116
        'Mechanical Engineer' => 'mechanical gears',
117
        'Mechanical Fitter' => 'pipe wrench',
118
        'Mechanic' => 'car jack',
119
        'Mobile Tyre Fitter' => 'tyre and wheel',
120
        'Mortgage Advisor' => 'house keys',
121
        'Multi Trade Operative' => 'multi-tool',
122
        'Nursery Manager' => 'toy building blocks',
123
        'Nursery Practitioner' => 'childrens storybook',
124
        'Nurse' => 'nurses cap',
125
        'Operations Manager' => 'operations dashboard',
126
        'Painter' => 'paint roller',
127
        'Parts Advisor' => 'car parts catalogue',
128
        'Passenger Assistant' => 'bus ticket machine',
129
        'Payroll Administrator' => 'payslip',
130
        'Payroll Specialist' => 'payroll software screen',
131
        'Personal Advisor' => 'advisory notepad',
132
        'Planning Officer' => 'town plan',
133
        'Plasterer' => 'plastering trowel',
134
        'Plumber' => 'pipe wrench and pipes',
135
        'Primary Teacher' => 'school bell',
136
        'Production Manager' => 'production line',
137
        'Production Operative' => 'assembly line component',
138
        'Production Supervisor' => 'quality control gauge',
139
        'Project Engineer' => 'project gantt chart',
140
        'Project Manager' => 'project plan board',
141
        'Property Manager' => 'set of property keys',
142
        'Quality Engineer' => 'caliper gauge',
143
        'Quality Inspector' => 'magnifying glass',
144
        'Quality Manager' => 'quality certificate',
145
        'Quantity Surveyor' => 'measuring tape and blueprints',
146
        'Reach Truck Driver' => 'reach truck',
147
        'Receptionist' => 'reception desk bell',
148
        'Recruitment Consultant' => 'CV document',
149
        'Refrigeration Engineer' => 'refrigeration unit',
150
        'Regional Sales Manager' => 'sales territory map',
151
        'Registered Manager' => 'care home registration certificate',
152
        'Research Associate' => 'microscope',
153
        'Residential Support Worker' => 'house key with lanyard',
154
        'Restaurant Team Member' => 'restaurant order pad',
155
        'Roofer' => 'roofing hammer',
156
        'Rough Sleeping Outreach Worker' => 'sleeping bag',
157
        'Sales Administrator' => 'sales order form',
158
        'Sales Advisor' => 'price tag',
159
        'Sales Consultant' => 'sales presentation',
160
        'Sales Engineer' => 'technical sales brochure',
161
        'Sales Executive' => 'business card',
162
        'Sales Manager' => 'sales trophy',
163
        'Sales Representative' => 'product sample kit',
164
        'Scaffolder' => 'scaffolding poles',
165
        'School Crossing Patrol' => 'lollipop stop sign',
166
        'Science Teacher' => 'laboratory flask',
167
        'Security Officer' => 'security badge',
168
        'SEN Teacher' => 'special education resource kit',
169
        'Senior Care Assistant' => 'medication trolley',
170
        'Service Advisor' => 'service desk terminal',
171
        'Service Engineer' => 'service toolbox',
172
        'Service Manager' => 'service level agreement',
173
        'Shift Engineer' => 'shift rota board',
174
        'Shift Leader' => 'team leader whistle',
175
        'Site Manager' => 'site plan',
176
        'Social Worker' => 'case file folder',
177
        'Software Engineer' => 'computer code on screen',
178
        'Solution Architect' => 'architecture diagram',
179
        'Store Manager' => 'retail shop front',
180
        'Structural Engineer' => 'structural beam drawing',
181
        'Supervisor' => 'supervisor clipboard',
182
        'Supply Teacher' => 'classroom whiteboard',
183
        'Support Worker' => 'support lanyard badge',
184
        'Teaching Assistant' => 'school exercise book',
185
        'Team Leader' => 'team whiteboard',
186
        'Technical Author' => 'technical manual',
187
        'Tiler' => 'tile cutter',
188
        'Transport Manager' => 'fleet management board',
189
        'Transport Planner' => 'route map',
190
        'Van Driver' => 'white van',
191
        'Vehicle Technician' => 'car diagnostic tool',
192
        'Warehouse Operative' => 'pallet of boxes',
193
        'Welder' => 'welding mask',
194
        'Window Installer' => 'window frame',
195
        'Workshop Controller' => 'workshop job board',
196
        'Workshop Technician' => 'workshop bench',
197
    ];
198

199
    /**
200
     * Keyword patterns for mapping job titles to canonical categories.
201
     * Each entry: [patterns, canonical_title]
202
     * patterns is an array of keyword sets - if ALL keywords in any set are found, it matches.
203
     * Checked in order; first match wins. More specific patterns should come first.
204
     */
205
    const JOB_KEYWORD_MAP = [
206
        # Driving - specific first
207
        [['hgv', 'class 1'], 'HGV Class 1 Driver'],
208
        [['hgv', 'class 2'], 'HGV Class 2 Driver'],
209
        [['class 1'], 'HGV Class 1 Driver'],
210
        [['class 2'], 'HGV Class 2 Driver'],
211
        [['7.5t'], 'Delivery Driver'],
212
        [['7.5 tonne'], 'Delivery Driver'],
213
        [['delivery driver'], 'Delivery Driver'],
214
        [['hgv driver'], 'HGV Class 1 Driver'],
215
        [['hgv technician'], 'HGV Technician'],
216
        [['van driver'], 'Van Driver'],
217
        [['bus driver'], 'Bus Driver'],
218
        [['trade plate driver'], 'Van Driver'],
219
        [['forklift'], 'Forklift Driver'],
220
        [['flt driver'], 'Forklift Driver'],
221
        [['reach truck'], 'Reach Truck Driver'],
222
        [['driver rep'], 'Delivery Driver'],
223

224
        # Engineering - specific first
225
        [['embedded software'], 'Embedded Software Engineer'],
226
        [['machine learning'], 'Machine Learning Engineer'],
227
        [['software engineer'], 'Software Engineer'],
228
        [['solution architect'], 'Solution Architect'],
229
        [['data architect'], 'Data Architect'],
230
        [['data engineer'], 'Data Engineer'],
231
        [['data analyst'], 'Data Analyst'],
232
        [['electrical design'], 'Electrical Engineer'],
233
        [['mechanical design'], 'Mechanical Design Engineer'],
234
        [['structural engineer'], 'Structural Engineer'],
235
        [['electrical engineer'], 'Electrical Engineer'],
236
        [['mechanical engineer'], 'Mechanical Engineer'],
237
        [['communications engineer'], 'Communications Engineer'],
238
        [['refrigeration engineer'], 'Refrigeration Engineer'],
239
        [['field service engineer'], 'Field Service Engineer'],
240
        [['service engineer'], 'Service Engineer'],
241
        [['shift engineer'], 'Shift Engineer'],
242
        [['gas engineer'], 'Gas Engineer'],
243
        [['project engineer'], 'Project Engineer'],
244
        [['design engineer'], 'Design Engineer'],
245
        [['manufacturing engineer'], 'Manufacturing Engineer'],
246
        [['quality engineer'], 'Quality Engineer'],
247
        [['sales engineer'], 'Sales Engineer'],
248
        [['automation engineer'], 'Mechanical Engineer'],
249
        [['battery', 'engineer'], 'Electrical Engineer'],
250
        [['multi skilled', 'engineer'], 'Maintenance Engineer'],
251
        [['multi-skilled', 'engineer'], 'Maintenance Engineer'],
252
        [['maintenance engineer'], 'Maintenance Engineer'],
253
        [['maintenance technician'], 'Maintenance Technician'],
254
        [['maintenance electrician'], 'Maintenance Electrician'],
255
        [['shift technician'], 'Maintenance Technician'],
256
        [['electrical technician'], 'Electrical Engineer'],
257
        [['mechanical technician'], 'Mechanical Engineer'],
258

259
        # Construction & trades
260
        [['quantity surveyor'], 'Quantity Surveyor'],
261
        [['building surveyor'], 'Building Surveyor'],
262
        [['building inspector'], 'Building Inspector'],
263
        [['site manager'], 'Site Manager'],
264
        [['construction', 'manager'], 'Construction Manager'],
265
        [['contracts manager'], 'Contracts Manager'],
266
        [['groundworker'], 'Groundworker'],
267
        [['scaffolder'], 'Scaffolder'],
268
        [['bricklayer'], 'Bricklayer'],
269
        [['plasterer'], 'Plasterer'],
270
        [['roofer'], 'Roofer'],
271
        [['tiler'], 'Tiler'],
272
        [['painter', 'decorator'], 'Painter'],
273
        [['paint sprayer'], 'Painter'],
274
        [['window', 'door', 'installer'], 'Window Installer'],
275
        [['window', 'installer'], 'Window Installer'],
276
        [['carpenter'], 'Carpenter'],
277
        [['multi trade'], 'Multi Trade Operative'],
278
        [['labourer'], 'Labourer'],
279
        [['cscs'], 'Labourer'],
280

281
        # Welding & machining
282
        [['cnc'], 'CNC Machinist'],
283
        [['tig welder'], 'Welder'],
284
        [['welder'], 'Welder'],
285
        [['fabricator'], 'Welder'],
286
        [['machine operator'], 'Machine Operator'],
287

288
        # Healthcare & care
289
        [['registered nurse'], 'Nurse'],
290
        [['dental nurse'], 'Dental Nurse'],
291
        [['nurse'], 'Nurse'],
292
        [['complex care'], 'Care Assistant'],
293
        [['care assistant'], 'Care Assistant'],
294
        [['care worker'], 'Care Worker'],
295
        [['care coordinator'], 'Care Coordinator'],
296
        [['care team leader'], 'Senior Care Assistant'],
297
        [['senior care'], 'Senior Care Assistant'],
298
        [['healthcare assistant'], 'Healthcare Assistant'],
299
        [['clinical assessor'], 'Clinical Assessor'],
300
        [['functional assessor'], 'Clinical Assessor'],
301
        [['support worker'], 'Support Worker'],
302
        [['residential', 'support'], 'Residential Support Worker'],
303
        [['outreach worker'], 'Rough Sleeping Outreach Worker'],
304

305
        # Social work
306
        [['social worker'], 'Social Worker'],
307
        [['personal advisor'], 'Personal Advisor'],
308

309
        # Education
310
        [['teaching assistant'], 'Teaching Assistant'],
311
        [['sen teacher'], 'SEN Teacher'],
312
        [['sen teaching'], 'Teaching Assistant'],
313
        [['supply teacher'], 'Supply Teacher'],
314
        [['primary teacher'], 'Primary Teacher'],
315
        [['english teacher'], 'Primary Teacher'],
316
        [['maths teacher'], 'Maths Teacher'],
317
        [['science teacher'], 'Science Teacher'],
318
        [['lecturer'], 'Lecturer'],
319
        [['headteacher'], 'Primary Teacher'],
320
        [['head of construction'], 'Lecturer'],
321
        [['cover supervisor'], 'Teaching Assistant'],
322
        [['learning support'], 'Teaching Assistant'],
323
        [['behaviour mentor'], 'Teaching Assistant'],
324

325
        # Sales - specific first
326
        [['field sales'], 'Field Sales Representative'],
327
        [['door to door'], 'Door Canvasser'],
328
        [['canvasser'], 'Door Canvasser'],
329
        [['car sales'], 'Sales Executive'],
330
        [['sales engineer'], 'Sales Engineer'],
331
        [['regional sales'], 'Regional Sales Manager'],
332
        [['area sales'], 'Regional Sales Manager'],
333
        [['sales manager'], 'Sales Manager'],
334
        [['sales director'], 'Sales Manager'],
335
        [['sales consultant'], 'Sales Consultant'],
336
        [['sales advisor'], 'Sales Advisor'],
337
        [['sales executive'], 'Sales Executive'],
338
        [['sales administrator'], 'Sales Administrator'],
339
        [['sales representative'], 'Sales Representative'],
340
        [['business development'], 'Business Development Manager'],
341
        [['account manager'], 'Account Manager'],
342
        [['key account'], 'Account Manager'],
343
        [['customer account'], 'Account Manager'],
344

345
        # Management
346
        [['general manager'], 'General Manager'],
347
        [['operations manager'], 'Operations Manager'],
348
        [['area manager'], 'Area Manager'],
349
        [['deputy manager'], 'Deputy Manager'],
350
        [['assistant manager'], 'Assistant Manager'],
351
        [['assistant store'], 'Assistant Manager'],
352
        [['store manager'], 'Store Manager'],
353
        [['branch manager'], 'Branch Manager'],
354
        [['home manager'], 'Home Manager'],
355
        [['registered manager'], 'Registered Manager'],
356
        [['nursery manager'], 'Nursery Manager'],
357
        [['property manager'], 'Property Manager'],
358
        [['project manager'], 'Project Manager'],
359
        [['design manager'], 'Design Manager'],
360
        [['marketing manager'], 'Marketing Manager'],
361
        [['finance manager'], 'Finance Manager'],
362
        [['transport manager'], 'Transport Manager'],
363
        [['maintenance manager'], 'Maintenance Manager'],
364
        [['production manager'], 'Production Manager'],
365
        [['service manager'], 'Service Manager'],
366
        [['aftersales manager'], 'General Manager'],
367
        [['bid manager'], 'Bid Manager'],
368
        [['commercial manager'], 'Contracts Manager'],
369

370
        # Finance & accounting
371
        [['financial controller'], 'Financial Controller'],
372
        [['management accountant'], 'Management Accountant'],
373
        [['finance business partner'], 'Finance Business Partner'],
374
        [['head of finance'], 'Head of Finance'],
375
        [['credit controller'], 'Credit Controller'],
376
        [['accountant'], 'Accountant'],
377
        [['accounts assistant'], 'Finance Assistant'],
378
        [['finance assistant'], 'Finance Assistant'],
379
        [['payroll specialist'], 'Payroll Specialist'],
380
        [['payroll administrator'], 'Payroll Administrator'],
381
        [['payroll'], 'Payroll Administrator'],
382
        [['estimator'], 'Estimator'],
383
        [['buyer'], 'Buyer'],
384
        [['mortgage'], 'Mortgage Advisor'],
385

386
        # HR
387
        [['hr business partner'], 'HR Business Partner'],
388
        [['hr advisor'], 'HR Advisor'],
389
        [['hr manager'], 'HR Advisor'],
390
        [['recruitment consultant'], 'Recruitment Consultant'],
391
        [['trainee recruitment'], 'Recruitment Consultant'],
392
        [['job coach'], 'Recruitment Consultant'],
393

394
        # Customer service
395
        [['customer service'], 'Customer Service Advisor'],
396
        [['customer relations'], 'Customer Service Advisor'],
397
        [['receptionist'], 'Receptionist'],
398
        [['helpdesk'], 'Receptionist'],
399

400
        # IT
401
        [['it apprentice'], 'IT Apprentice'],
402
        [['it support'], 'IT Support'],
403
        [['cyber security'], 'IT Support'],
404
        [['technical architect'], 'Solution Architect'],
405
        [['technical specialist'], 'IT Support'],
406

407
        # Hospitality & retail
408
        [['chef'], 'Chef'],
409
        [['cook'], 'Cook'],
410
        [['kitchen assistant'], 'Kitchen Assistant'],
411
        [['kitchen designer'], 'Kitchen Designer'],
412
        [['chip shop'], 'Cook'],
413
        [['food & beverage'], 'Catering Assistant'],
414
        [['food coordinator'], 'Catering Assistant'],
415
        [['restaurant team'], 'Restaurant Team Member'],
416
        [['bar team'], 'Bartender'],
417
        [['pizza venue'], 'Cook'],
418
        [['burger venue'], 'Cook'],
419
        [['supermarket team'], 'Cashier'],
420
        [['customer assistant'], 'Cashier'],
421
        [['housekeeper'], 'Housekeeper'],
422
        [['housekeeping'], 'Housekeeper'],
423

424
        # Automotive
425
        [['vehicle technician'], 'Vehicle Technician'],
426
        [['motor vehicle'], 'Vehicle Technician'],
427
        [['mechanic'], 'Mechanic'],
428
        [['tyre fitter'], 'Mobile Tyre Fitter'],
429
        [['parts advisor'], 'Parts Advisor'],
430
        [['service advisor'], 'Service Advisor'],
431
        [['workshop controller'], 'Workshop Controller'],
432
        [['workshop technician'], 'Workshop Technician'],
433

434
        # Warehouse & logistics
435
        [['warehouse'], 'Warehouse Operative'],
436
        [['transport planner'], 'Transport Planner'],
437

438
        # Security
439
        [['security officer'], 'Security Officer'],
440
        [['security'], 'Security Officer'],
441
        [['sia'], 'Security Officer'],
442

443
        # Production & manufacturing
444
        [['production operative'], 'Production Operative'],
445
        [['production supervisor'], 'Production Supervisor'],
446
        [['cleaning operative'], 'Cleaner'],
447
        [['cleaner'], 'Cleaner'],
448
        [['cleaning'], 'Cleaner'],
449

450
        # Research & academic
451
        [['research'], 'Research Associate'],
452
        [['postdoctoral'], 'Research Associate'],
453
        [['professor'], 'Lecturer'],
454

455
        # Children & nursery
456
        [['nursery practitioner'], 'Nursery Practitioner'],
457
        [['nursery'], 'Nursery Practitioner'],
458

459
        # Other specific roles
460
        [['plumber'], 'Plumber'],
461
        [['electrician'], 'Electrician'],
462
        [['mechanical fitter'], 'Mechanical Fitter'],
463
        [['installer'], 'Installer'],
464
        [['document controller'], 'Document Controller'],
465
        [['technical author'], 'Technical Author'],
466
        [['digital marketing'], 'Digital Marketing Executive'],
467
        [['head of marketing'], 'Head of Marketing'],
468
        [['cad technician'], 'CAD Technician'],
469
        [['architectural'], 'Architect'],
470
        [['architect'], 'Architect'],
471
        [['ecologist'], 'Ecologist'],
472
        [['lifeguard'], 'Lifeguard'],
473
        [['fundraiser'], 'Fundraiser'],
474
        [['compliance'], 'Compliance Officer'],
475
        [['planning officer'], 'Planning Officer'],
476
        [['planning enforcement'], 'Planning Officer'],
477
        [['housing officer'], 'Property Manager'],
478
        [['library assistant'], 'Administrator'],
479
        [['contractor escort'], 'Security Officer'],
480
        [['school crossing'], 'School Crossing Patrol'],
481
        [['passenger assistant'], 'Passenger Assistant'],
482
        [['activities coordinator'], 'Activities Coordinator'],
483
        [['activities team'], 'Activities Coordinator'],
484
        [['team leader'], 'Team Leader'],
485
        [['shift leader'], 'Shift Leader'],
486
        [['supervisor'], 'Supervisor'],
487
        [['administrator'], 'Administrator'],
488
        [['admin'], 'Administrator'],
489
        [['coordinator'], 'Administrator'],
490
        [['legal secretary'], 'Legal Secretary'],
491
        [['secretary'], 'Legal Secretary'],
492
        [['costs draftsperson'], 'Legal Secretary'],
493
        [['surveyor'], 'Building Surveyor'],
494
        [['engineer'], 'Maintenance Engineer'],
495
        [['technician'], 'Maintenance Technician'],
496
        [['operative'], 'Factory Operative'],
497
        [['driver'], 'Delivery Driver'],
498
        [['manager'], 'General Manager'],
499
        [['assistant'], 'Administrator'],
500
        [['teacher'], 'Primary Teacher'],
501
        [['worker'], 'Support Worker'],
502

503
        # Catch-all broader patterns (must be last)
504
        [['multi-skilled', 'engineer'], 'Maintenance Engineer'],
505
        [['green & clean'], 'Cleaner'],
506
        [['green and clean'], 'Cleaner'],
507
        [['children\'s home'], 'Residential Support Worker'],
508
        [['childrens home'], 'Residential Support Worker'],
509
        [['tenancy support'], 'Support Worker'],
510
        [['process technologist'], 'Quality Engineer'],
511
        [['digital transformation'], 'IT Support'],
512
        [['software developer'], 'Software Engineer'],
513
        [['data scientist'], 'Data Analyst'],
514
        [['kitchen porter'], 'Kitchen Assistant'],
515
        [['merchandiser'], 'Sales Advisor'],
516
        [['joiner'], 'Carpenter'],
517
        [['marketing executive'], 'Digital Marketing Executive'],
518
        [['conveyancer'], 'Legal Secretary'],
519
        [['valeter'], 'Cleaner'],
520
        [['site agent'], 'Site Manager'],
521
        [['consultant'], 'Account Manager'],
522
        [['analyst'], 'Data Analyst'],
523
        [['clerk'], 'Finance Assistant'],
524
        [['developer'], 'Software Engineer'],
525
        [['director'], 'General Manager'],
526
        [['officer'], 'Administrator'],
527
        [['adviser'], 'Customer Service Advisor'],
528
        [['advisor'], 'Customer Service Advisor'],
529
        [['carer'], 'Care Worker'],
530
        [['porter'], 'Warehouse Operative'],
531
        [['agent'], 'Sales Advisor'],
532
        [['member'], 'Cashier'],
533
        [['registrar'], 'Administrator'],
534
        [['principal'], 'General Manager'],
535
        [['head of'], 'General Manager'],
536
        [['senior'], 'General Manager'],
537
        [['lead'], 'General Manager'],
538
    ];
539

540
    # File-based cache location.
541
    const CACHE_FILE = '/tmp/pollinations_hashes.json';
542

543
    # Cache expiry in seconds (24 hours).
544
    const CACHE_EXPIRY = 86400;
545

546
    # File-based cache for failed items.
547
    const FAILED_CACHE_FILE = '/tmp/pollinations_failed.json';
548

549
    # Max failures before permanently skipping an item.
550
    const MAX_FAILURES = 3;
551

552
    # Failed items cache expiry (1 day - give items a chance to work later).
553
    const FAILED_CACHE_EXPIRY = 86400;
554

555
    # OpenAI API endpoint for vision.
556
    const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
557

558
    /**
559
     * Load the file-based hash cache.
560
     * @return array Hash => [prompt, timestamp] mapping.
561
     */
562
    private static function loadFileCache() {
563
        if (!file_exists(self::CACHE_FILE)) {
×
564
            return [];
×
565
        }
566

567
        $data = @file_get_contents(self::CACHE_FILE);
×
568
        if (!$data) {
×
569
            return [];
×
570
        }
571

572
        $cache = @json_decode($data, TRUE);
×
573
        if (!is_array($cache)) {
×
574
            return [];
×
575
        }
576

577
        # Remove expired entries.
578
        $now = time();
×
579
        $cache = array_filter($cache, function($entry) use ($now) {
×
580
            return isset($entry['timestamp']) && ($now - $entry['timestamp']) < self::CACHE_EXPIRY;
×
581
        });
×
582

583
        return $cache;
×
584
    }
585

586
    /**
587
     * Save the file-based hash cache.
588
     * @param array $cache Hash => [prompt, timestamp] mapping.
589
     */
590
    private static function saveFileCache($cache) {
591
        # Use file locking to prevent race conditions.
592
        $fp = @fopen(self::CACHE_FILE, 'c');
×
593
        if (!$fp) {
×
594
            return;
×
595
        }
596

597
        if (flock($fp, LOCK_EX)) {
×
598
            ftruncate($fp, 0);
×
599
            fwrite($fp, json_encode($cache));
×
600
            fflush($fp);
×
601
            flock($fp, LOCK_UN);
×
602
        }
603

604
        fclose($fp);
×
605
    }
606

607
    /**
608
     * Check if a hash exists in the file cache for a different prompt.
609
     * @param string $hash Image hash.
610
     * @param string $prompt Current prompt.
611
     * @return string|false The existing prompt if duplicate found, FALSE otherwise.
612
     */
613
    private static function checkFileCache($hash, $prompt) {
614
        $cache = self::loadFileCache();
×
615

616
        if (isset($cache[$hash]) && $cache[$hash]['prompt'] !== $prompt) {
×
617
            return $cache[$hash]['prompt'];
×
618
        }
619

620
        return FALSE;
×
621
    }
622

623
    /**
624
     * Add a hash to the file cache.
625
     * @param string $hash Image hash.
626
     * @param string $prompt The prompt that generated this image.
627
     */
628
    private static function addToFileCache($hash, $prompt) {
629
        $cache = self::loadFileCache();
×
630

631
        $cache[$hash] = [
×
632
            'prompt' => $prompt,
×
633
            'timestamp' => time()
×
634
        ];
×
635

636
        self::saveFileCache($cache);
×
637
    }
638

639
    /**
640
     * Load the failed items cache.
641
     * @return array itemName => ['count' => int, 'timestamp' => int]
642
     */
643
    private static function loadFailedCache() {
644
        if (!file_exists(self::FAILED_CACHE_FILE)) {
×
645
            return [];
×
646
        }
647

648
        $data = @file_get_contents(self::FAILED_CACHE_FILE);
×
649
        if (!$data) {
×
650
            return [];
×
651
        }
652

653
        $cache = @json_decode($data, TRUE);
×
654
        if (!is_array($cache)) {
×
655
            return [];
×
656
        }
657

658
        # Remove expired entries.
659
        $now = time();
×
660
        $cache = array_filter($cache, function($entry) use ($now) {
×
661
            return isset($entry['timestamp']) && ($now - $entry['timestamp']) < self::FAILED_CACHE_EXPIRY;
×
662
        });
×
663

664
        return $cache;
×
665
    }
666

667
    /**
668
     * Save the failed items cache.
669
     * @param array $cache itemName => ['count' => int, 'timestamp' => int]
670
     */
671
    private static function saveFailedCache($cache) {
672
        $fp = @fopen(self::FAILED_CACHE_FILE, 'c');
×
673
        if (!$fp) {
×
674
            return;
×
675
        }
676

677
        if (flock($fp, LOCK_EX)) {
×
678
            ftruncate($fp, 0);
×
679
            fwrite($fp, json_encode($cache));
×
680
            fflush($fp);
×
681
            flock($fp, LOCK_UN);
×
682
        }
683

684
        fclose($fp);
×
685
    }
686

687
    /**
688
     * Record a failure for an item. Returns TRUE if item should be skipped (too many failures).
689
     * @param string $itemName The item name that failed.
690
     * @return bool TRUE if item has exceeded max failures and should be skipped.
691
     */
692
    public static function recordFailure($itemName) {
693
        $cache = self::loadFailedCache();
×
694

695
        if (!isset($cache[$itemName])) {
×
696
            $cache[$itemName] = ['count' => 0, 'timestamp' => time()];
×
697
        }
698

699
        $cache[$itemName]['count']++;
×
700
        $cache[$itemName]['timestamp'] = time();
×
701

702
        self::saveFailedCache($cache);
×
703

704
        $shouldSkip = $cache[$itemName]['count'] >= self::MAX_FAILURES;
×
705
        if ($shouldSkip) {
×
706
            error_log("Item '$itemName' has failed " . $cache[$itemName]['count'] . " times, will skip for 1 day");
×
707
        }
708

709
        return $shouldSkip;
×
710
    }
711

712
    /**
713
     * Check if an item should be skipped due to previous failures.
714
     * @param string $itemName The item name to check.
715
     * @return bool TRUE if item should be skipped.
716
     */
717
    public static function shouldSkipItem($itemName) {
718
        $cache = self::loadFailedCache();
×
719

720
        if (!isset($cache[$itemName])) {
×
721
            return FALSE;
×
722
        }
723

724
        return $cache[$itemName]['count'] >= self::MAX_FAILURES;
×
725
    }
726

727
    /**
728
     * Clear the failed items cache (mainly for testing/maintenance).
729
     */
730
    public static function clearFailedCache() {
731
        if (file_exists(self::FAILED_CACHE_FILE)) {
×
732
            @unlink(self::FAILED_CACHE_FILE);
×
733
        }
734
    }
735

736
    /**
737
     * Build the HTTP context for Pollinations API requests.
738
     *
739
     * @param int $timeout Timeout in seconds.
740
     * @return resource Stream context for HTTP requests.
741
     */
742
    private static function buildHttpContext($timeout) {
743
        return stream_context_create([
×
744
            'http' => [
×
745
                'timeout' => $timeout
×
746
            ]
×
747
        ]);
×
748
    }
749

750
    /**
751
     * Build the Pollinations image URL with optional API key.
752
     *
753
     * @param string $prompt The prompt (will be URL-encoded).
754
     * @param int $width Image width.
755
     * @param int $height Image height.
756
     * @return string The full URL.
757
     */
758
    private static function buildImageUrl($prompt, $width, $height) {
759
        $url = "https://image.pollinations.ai/prompt/" . urlencode($prompt) .
×
760
               "?width={$width}&height={$height}&nologo=true&seed=1";
×
761

762
        # Add API key if configured.
763
        if (defined('POLLINATIONS_API_KEY') && POLLINATIONS_API_KEY) {
×
764
            $url .= "&key=" . urlencode(POLLINATIONS_API_KEY);
×
765
        }
766

767
        return $url;
×
768
    }
769

770
    /**
771
     * Fetch an image from Pollinations.ai for the given prompt.
772
     * Returns image data on success, or FALSE if rate-limited/failed.
773
     *
774
     * @param string $prompt The item name/description to generate an image for.
775
     * @param string $fullPrompt The full prompt string to send to Pollinations.
776
     * @param int $width Image width.
777
     * @param int $height Image height.
778
     * @param int $timeout Timeout in seconds.
779
     * @return string|false Image data on success, FALSE on failure or rate-limiting.
780
     */
781
    public static function fetchImage($prompt, $fullPrompt, $width = 640, $height = 480, $timeout = 120) {
782
        global $dbhr;
783

784
        $url = self::buildImageUrl($fullPrompt, $width, $height);
×
785

786
        $ctx = self::buildHttpContext($timeout);
×
787

788
        $data = @file_get_contents($url, FALSE, $ctx);
×
789

790
        # Check for HTTP 429 rate limiting.
791
        if (isset($http_response_header)) {
×
792
            foreach ($http_response_header as $header) {
×
793
                if (preg_match('/^HTTP\/\d+\.?\d*\s+429/', $header)) {
×
794
                    error_log("Pollinations rate limited (HTTP 429) for: " . $prompt);
×
795
                    return FALSE;
×
796
                }
797
            }
798
        }
799

800
        if (!$data || strlen($data) == 0) {
×
801
            error_log("Pollinations failed to return data for: " . $prompt);
×
802
            return FALSE;
×
803
        }
804

805
        # Compute hash of received image.
806
        $hash = md5($data);
×
807

808
        # Check 1: In-memory cache (same process).
809
        if (isset(self::$seenHashes[$hash]) && self::$seenHashes[$hash] !== $prompt) {
×
810
            error_log("Pollinations rate limited (in-memory duplicate) for: " . $prompt .
×
811
                      " (same image as: " . self::$seenHashes[$hash] . ")");
×
812
            # Clean up the recently added entry that we now know is rate-limited.
813
            self::cleanupRateLimitedHash($hash);
×
814
            return FALSE;
×
815
        }
816

817
        # Check 2: File-based cache (across processes).
818
        $existingPrompt = self::checkFileCache($hash, $prompt);
×
819
        if ($existingPrompt !== FALSE) {
×
820
            error_log("Pollinations rate limited (file cache duplicate) for: " . $prompt .
×
821
                      " (same image as: " . $existingPrompt . ")");
×
822
            # Clean up recently added entries with this hash.
823
            self::cleanupRateLimitedHash($hash);
×
824
            return FALSE;
×
825
        }
826

827
        # Check 3: Database (historical data).
828
        if ($dbhr) {
×
829
            $existing = $dbhr->preQuery(
×
830
                "SELECT name FROM ai_images WHERE imagehash = ? AND name != ? LIMIT 1",
×
831
                [$hash, $prompt]
×
832
            );
×
833

834
            if (count($existing) > 0) {
×
835
                error_log("Pollinations rate limited (DB duplicate) for: " . $prompt .
×
836
                          " (same image as: " . $existing[0]['name'] . ")");
×
837
                # Also add to file cache to speed up future checks.
838
                self::addToFileCache($hash, $existing[0]['name']);
×
839
                return FALSE;
×
840
            }
841
        }
842

843
        # All checks passed - track this hash.
844
        self::$seenHashes[$hash] = $prompt;
×
845
        self::addToFileCache($hash, $prompt);
×
846

847
        return $data;
×
848
    }
849

850
    /**
851
     * Clean up recently added ai_images entries with a rate-limited hash.
852
     * Only removes entries added in the last hour to avoid removing legitimate old entries.
853
     * @param string $hash The rate-limited image hash.
854
     */
855
    private static function cleanupRateLimitedHash($hash) {
856
        global $dbhm;
857

858
        if (!$dbhm) {
×
859
            return;
×
860
        }
861

862
        # Only clean up entries added in the last hour - these are likely from this rate-limiting event.
863
        $deleted = $dbhm->preExec(
×
864
            "DELETE FROM ai_images WHERE imagehash = ? AND created > DATE_SUB(NOW(), INTERVAL 1 HOUR)",
×
865
            [$hash]
×
866
        );
×
867

868
        if ($deleted) {
×
869
            $count = $dbhm->rowsAffected();
×
870
            if ($count > 0) {
×
871
                error_log("Cleaned up $count recent ai_images entries with rate-limited hash: $hash");
×
872
            }
873
        }
874

875
        # Also clean up messages_attachments that were recently added with externaluids
876
        # that are now known to be rate-limited (i.e., the ai_images entry was just deleted).
877
        # We look for recent AI attachments that no longer have a matching ai_images entry.
878
        $minId = $dbhm->preQuery("SELECT COALESCE(MAX(id), 0) - 10000 as minid FROM messages_attachments");
×
879
        $minIdVal = $minId[0]['minid'] ?? 0;
×
880

881
        $orphaned = $dbhm->preExec(
×
882
            "DELETE ma FROM messages_attachments ma
×
883
             LEFT JOIN ai_images ai ON ma.externaluid = ai.externaluid
884
             WHERE ma.externaluid LIKE 'freegletusd-%'
885
             AND JSON_EXTRACT(ma.externalmods, '$.ai') = TRUE
886
             AND ai.id IS NULL
887
             AND ma.id > ?",
×
888
            [$minIdVal]
×
889
        );
×
890

891
        if ($orphaned) {
×
892
            $count = $dbhm->rowsAffected();
×
893
            if ($count > 0) {
×
894
                error_log("Cleaned up $count orphaned message attachments");
×
895
            }
896
        }
897
    }
898

899
    /**
900
     * Build a prompt for a message illustration.
901
     * @param string $itemName The item name.
902
     * @return string The full prompt.
903
     */
904
    public static function buildMessagePrompt($itemName) {
905
        # Prompt injection defense.
906
        $cleanName = str_replace('CRITICAL:', '', $itemName);
9✔
907
        $cleanName = str_replace('Draw only', '', $cleanName);
9✔
908

909
        # Use positive framing only - negative prompts can backfire with diffusion models.
910
        # Avoid mentioning specific item categories (clothing, furniture) as this can cause
911
        # the model to include them even when the actual item is unrelated.
912
        return "Product illustration: single isolated " . $cleanName . " centered on plain dark green background. " .
9✔
913
               "Style: friendly cartoon white line drawing, moderate shading, cute and quirky, UK audience. " .
9✔
914
               "The object sits alone on a simple surface or floats in space. " .
9✔
915
               "Simple illustration style, clean lines, single object only.";
9✔
916
    }
917

918
    /**
919
     * Map a job title to a canonical job category using pure PHP keyword matching.
920
     * No API calls. Returns NULL for unmapped titles.
921
     *
922
     * @param string $title The raw job title.
923
     * @return string|null The canonical job title, or NULL if no match.
924
     */
925
    public static function canonicalJobTitle($title) {
926
        if (empty($title)) {
23✔
927
            return NULL;
1✔
928
        }
929

930
        # Clean: lowercase, strip location suffixes, ref numbers, trim.
931
        $clean = strtolower(trim($title));
22✔
932

933
        # Strip location suffixes like "- Portsmouth", "- Durham", "- Haven"
934
        # Require space before dash to avoid matching hyphenated words like "Multi-Skilled"
935
        $clean = preg_replace('/\s+-\s+[A-Z][a-zA-Z\s&\']+$/', '', trim($title));
22✔
936
        $clean = strtolower(trim($clean));
22✔
937

938
        # Strip parenthetical locations like "(Nottingham, Nottinghamshire, GB, NG1 1DQ)"
939
        $clean = preg_replace('/\s*\([^)]*\)\s*/', ' ', $clean);
22✔
940

941
        # Strip "IKEA xxx Store" suffixes
942
        $clean = preg_replace('/\s*-\s*ikea\s+\w+\s+store$/i', '', $clean);
22✔
943

944
        # Strip trailing location with postcodes
945
        $clean = preg_replace('/\s*-\s*[a-z]+,?\s+[a-z]+shire$/i', '', $clean);
22✔
946

947
        # Strip "- Haven" type suffixes that may remain
948
        $clean = preg_replace('/\s*-\s*haven$/i', '', $clean);
22✔
949

950
        # Strip college/institution suffixes
951
        $clean = preg_replace('/\s*-\s*\w+\s+college\s*.*$/i', '', $clean);
22✔
952

953
        $clean = trim($clean);
22✔
954

955
        # First: check if cleaned title exactly matches a canonical title (case-insensitive).
956
        foreach (self::CANONICAL_JOBS as $canonical => $object) {
22✔
957
            if (strtolower($canonical) === $clean) {
22✔
958
                return $canonical;
6✔
959
            }
960
        }
961

962
        # Second: keyword pattern matching.
963
        foreach (self::JOB_KEYWORD_MAP as $entry) {
17✔
964
            $patterns = $entry[0];
17✔
965
            $canonical = $entry[1];
17✔
966

967
            # All keywords in the pattern must be present in the cleaned title.
968
            $allMatch = TRUE;
17✔
969
            foreach ($patterns as $keyword) {
17✔
970
                if (strpos($clean, strtolower($keyword)) === FALSE) {
17✔
971
                    $allMatch = FALSE;
17✔
972
                    break;
17✔
973
                }
974
            }
975

976
            if ($allMatch) {
17✔
977
                return $canonical;
16✔
978
            }
979
        }
980

981
        return NULL;
1✔
982
    }
983

984
    /**
985
     * Use GPT to map a job title to an iconic inanimate object/tool for that job.
986
     * This avoids generating images of people by never mentioning the profession
987
     * in the image prompt.
988
     *
989
     * @param string $jobTitle The job title.
990
     * @return string|false The object name, or FALSE on failure.
991
     */
992
    public static function objectForJob($jobTitle) {
993
        if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
×
994
            return FALSE;
×
995
        }
996

997
        $cleanName = str_replace('CRITICAL:', '', $jobTitle);
×
998
        $cleanName = str_replace('Draw only', '', $cleanName);
×
999

1000
        $payload = [
×
1001
            'model' => 'gpt-4o-mini',
×
1002
            'messages' => [
×
1003
                [
×
1004
                    'role' => 'system',
×
1005
                    'content' => 'You map job titles to a single iconic inanimate object or tool associated with that job. Reply with ONLY the object name, nothing else. No people, no body parts, no living things. Just the object.'
×
1006
                ],
×
1007
                [
×
1008
                    'role' => 'user',
×
1009
                    'content' => $cleanName
×
1010
                ]
×
1011
            ],
×
1012
            'max_tokens' => 20
×
1013
        ];
×
1014

1015
        $ch = curl_init(self::OPENAI_API_URL);
×
1016
        curl_setopt_array($ch, [
×
1017
            CURLOPT_RETURNTRANSFER => TRUE,
×
1018
            CURLOPT_POST => TRUE,
×
1019
            CURLOPT_POSTFIELDS => json_encode($payload),
×
1020
            CURLOPT_HTTPHEADER => [
×
1021
                'Content-Type: application/json',
×
1022
                'Authorization: Bearer ' . OPENAI_API_KEY
×
1023
            ],
×
1024
            CURLOPT_TIMEOUT => 15
×
1025
        ]);
×
1026

1027
        $response = curl_exec($ch);
×
1028
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
1029
        curl_close($ch);
×
1030

1031
        if ($httpCode !== 200 || !$response) {
×
1032
            error_log("objectForJob failed for '$jobTitle': HTTP $httpCode");
×
1033
            return FALSE;
×
1034
        }
1035

1036
        $data = json_decode($response, TRUE);
×
1037

1038
        if (!isset($data['choices'][0]['message']['content'])) {
×
1039
            error_log("objectForJob failed for '$jobTitle': unexpected response");
×
1040
            return FALSE;
×
1041
        }
1042

1043
        return trim($data['choices'][0]['message']['content']);
×
1044
    }
1045

1046
    /**
1047
     * Build a prompt for a job illustration.
1048
     * Uses the same object-focused prompt as messages to avoid generating people.
1049
     * @param string $objectName The object name (from objectForJob).
1050
     * @return string The full prompt.
1051
     */
1052
    public static function buildJobPrompt($objectName) {
1053
        return self::buildMessagePrompt($objectName);
3✔
1054
    }
1055

1056
    /**
1057
     * Fetch a batch of images, returning successful results and tracking failures.
1058
     * Individual item failures (no data returned) do NOT fail the batch - only actual
1059
     * rate-limiting (HTTP 429 or duplicate images) fails the entire batch.
1060
     *
1061
     * @param array $items Array of ['name' => string, 'prompt' => string, 'width' => int, 'height' => int]
1062
     * @param int $timeout Timeout per request in seconds.
1063
     * @return array|false Array with 'results' and 'failed' keys on success, FALSE if rate-limited.
1064
     *                     'results' => [['name' => string, 'data' => string, 'hash' => string], ...]
1065
     *                     'failed' => [name => TRUE, ...] items that failed to fetch (not rate-limited)
1066
     */
1067
    public static function fetchBatch($items, $timeout = 120) {
1068
        if (empty($items)) {
×
1069
            return ['results' => [], 'failed' => []];
×
1070
        }
1071

1072
        $results = [];
×
1073
        $failed = [];
×
1074
        $batchHashes = [];
×
1075

1076
        foreach ($items as $item) {
×
1077
            $name = $item['name'];
×
1078
            $prompt = $item['prompt'];
×
1079
            $width = $item['width'] ?? 640;
×
1080
            $height = $item['height'] ?? 480;
×
1081

1082
            $url = self::buildImageUrl($prompt, $width, $height);
×
1083

1084
            $ctx = self::buildHttpContext($timeout);
×
1085

1086
            error_log("Batch fetching image for: $name");
×
1087
            $data = @file_get_contents($url, FALSE, $ctx);
×
1088

1089
            # Check for HTTP 429 - this is real rate-limiting, fail entire batch.
1090
            if (isset($http_response_header)) {
×
1091
                foreach ($http_response_header as $header) {
×
1092
                    if (preg_match('/^HTTP\/\d+\.?\d*\s+429/', $header)) {
×
1093
                        error_log("Pollinations rate limited (HTTP 429) for batch item: $name");
×
1094
                        return FALSE;
×
1095
                    }
1096
                }
1097
            }
1098

1099
            # No data returned - this is an individual failure, NOT rate-limiting.
1100
            # Skip this item but continue with others.
1101
            if (!$data || strlen($data) == 0) {
×
1102
                error_log("Pollinations failed to return data for batch item: $name (skipping)");
×
1103
                $failed[$name] = TRUE;
×
1104
                continue;
×
1105
            }
1106

1107
            $hash = md5($data);
×
1108

1109
            # Check if this hash already appeared in this batch for a different item.
1110
            # This IS rate-limiting - fail entire batch.
1111
            if (isset($batchHashes[$hash]) && $batchHashes[$hash] !== $name) {
×
1112
                error_log("Pollinations rate limited (batch duplicate) for: $name (same as: " . $batchHashes[$hash] . ")");
×
1113
                # Clean up any recently added entries with this hash.
1114
                self::cleanupRateLimitedHash($hash);
×
1115
                return FALSE;
×
1116
            }
1117

1118
            # Check file cache - this IS rate-limiting.
1119
            $existingPrompt = self::checkFileCache($hash, $name);
×
1120
            if ($existingPrompt !== FALSE) {
×
1121
                error_log("Pollinations rate limited (file cache) for batch item: $name (same as: $existingPrompt)");
×
1122
                self::cleanupRateLimitedHash($hash);
×
1123
                return FALSE;
×
1124
            }
1125

1126
            # Check database for historical duplicates - this IS rate-limiting.
1127
            global $dbhr;
1128
            if ($dbhr) {
×
1129
                $existing = $dbhr->preQuery(
×
1130
                    "SELECT name FROM ai_images WHERE imagehash = ? AND name != ? LIMIT 1",
×
1131
                    [$hash, $name]
×
1132
                );
×
1133

1134
                if (count($existing) > 0) {
×
1135
                    error_log("Pollinations rate limited (DB) for batch item: $name (same as: " . $existing[0]['name'] . ")");
×
1136
                    self::addToFileCache($hash, $existing[0]['name']);
×
1137
                    self::cleanupRateLimitedHash($hash);
×
1138
                    return FALSE;
×
1139
                }
1140
            }
1141

1142
            # Safety check: verify image doesn't contain people.
1143
            $hasPeople = self::checkForPeople($data, $name);
×
1144
            if ($hasPeople === TRUE) {
×
1145
                error_log("Rejecting image for '$name' - contains people");
×
1146
                $failed[$name] = TRUE;
×
1147
                continue;
×
1148
            }
1149

1150
            $batchHashes[$hash] = $name;
×
1151
            $results[] = [
×
1152
                'name' => $name,
×
1153
                'data' => $data,
×
1154
                'hash' => $hash,
×
1155
                'msgid' => $item['msgid'] ?? NULL,
×
1156
                'jobid' => $item['jobid'] ?? NULL
×
1157
            ];
×
1158

1159
            # Small delay between requests.
1160
            sleep(1);
×
1161
        }
1162

1163
        # Add successful images to caches.
1164
        foreach ($results as $result) {
×
1165
            self::$seenHashes[$result['hash']] = $result['name'];
×
1166
            self::addToFileCache($result['hash'], $result['name']);
×
1167
        }
1168

1169
        return ['results' => $results, 'failed' => $failed];
×
1170
    }
1171

1172
    /**
1173
     * Cache an image in ai_images table.
1174
     * @param string $name The item/job name.
1175
     * @param string $uid The externaluid.
1176
     * @param string $hash Image hash.
1177
     */
1178
    public static function cacheImage($name, $uid, $hash) {
1179
        global $dbhm;
1180

1181
        if ($dbhm) {
×
1182
            $dbhm->preExec(
×
1183
                "INSERT INTO ai_images (name, externaluid, imagehash) VALUES (?, ?, ?)
×
1184
                 ON DUPLICATE KEY UPDATE externaluid = VALUES(externaluid), imagehash = VALUES(imagehash), created = NOW()",
×
1185
                [$name, $uid, $hash]
×
1186
            );
×
1187
        }
1188
    }
1189

1190
    /**
1191
     * Upload image to TUS and cache in ai_images table.
1192
     * Applies duotone filter before uploading to ensure consistent appearance in emails.
1193
     * @param string $name The item/job name.
1194
     * @param string $data Image data.
1195
     * @param string $hash Image hash.
1196
     * @return string|false The externaluid on success, FALSE on failure.
1197
     */
1198
    public static function uploadAndCache($name, $data, $hash) {
1199
        # Apply duotone filter to ensure consistent green/white color scheme.
1200
        # This bakes the effect into the image so it works in emails where CSS filters don't.
1201
        $img = new Image($data);
×
1202
        if ($img->img) {
×
1203
            $img->duotoneGreen();
×
1204
            $data = $img->getData(90);
×
1205

1206
            if (!$data) {
×
1207
                error_log("Failed to apply duotone filter for: $name");
×
1208
                return FALSE;
×
1209
            }
1210
        }
1211

1212
        $t = new Tus();
×
1213
        $tusUrl = $t->upload(NULL, 'image/jpeg', $data);
×
1214

1215
        if (!$tusUrl) {
×
1216
            error_log("Failed to upload image to TUS for: $name");
×
1217
            return FALSE;
×
1218
        }
1219

1220
        $uid = 'freegletusd-' . basename($tusUrl);
×
1221
        self::cacheImage($name, $uid, $hash);
×
1222

1223
        return $uid;
×
1224
    }
1225

1226
    /**
1227
     * Get the hash of image data.
1228
     * @param string $data Image data.
1229
     * @return string MD5 hash.
1230
     */
1231
    public static function getImageHash($data) {
1232
        return md5($data);
3✔
1233
    }
1234

1235
    /**
1236
     * Clear the in-memory hash cache (mainly for testing).
1237
     */
1238
    public static function clearCache() {
1239
        self::$seenHashes = [];
37✔
1240
    }
1241

1242
    /**
1243
     * Clear the file-based hash cache (mainly for testing/maintenance).
1244
     */
1245
    public static function clearFileCache() {
1246
        if (file_exists(self::CACHE_FILE)) {
37✔
1247
            @unlink(self::CACHE_FILE);
×
1248
        }
1249
    }
1250

1251
    /**
1252
     * Check if an image contains people using GPT-4o-mini vision.
1253
     * Only runs if OPENAI_API_KEY is defined in config.
1254
     *
1255
     * @param string $imageData Raw image data (not base64 encoded).
1256
     * @param string $itemName Item name for logging.
1257
     * @return bool|null TRUE if people detected, FALSE if not, NULL if check unavailable/failed.
1258
     */
1259
    public static function checkForPeople($imageData, $itemName = '') {
1260
        # Only run check if OpenAI API key is configured.
1261
        if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
×
1262
            return NULL;
×
1263
        }
1264

1265
        # Convert image to base64 data URL.
1266
        $base64 = base64_encode($imageData);
×
1267
        $mimeType = 'image/jpeg';
×
1268

1269
        # Try to detect actual mime type.
1270
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
1271
        $detected = $finfo->buffer($imageData);
×
1272
        if ($detected && strpos($detected, 'image/') === 0) {
×
1273
            $mimeType = $detected;
×
1274
        }
1275

1276
        $dataUrl = "data:{$mimeType};base64,{$base64}";
×
1277

1278
        $payload = [
×
1279
            'model' => 'gpt-4o-mini',
×
1280
            'messages' => [
×
1281
                [
×
1282
                    'role' => 'user',
×
1283
                    'content' => [
×
1284
                        [
×
1285
                            'type' => 'text',
×
1286
                            'text' => 'Does this image contain any people, human figures, hands, arms, legs, or body parts? Answer only YES or NO.'
×
1287
                        ],
×
1288
                        [
×
1289
                            'type' => 'image_url',
×
1290
                            'image_url' => [
×
1291
                                'url' => $dataUrl,
×
1292
                                'detail' => 'low'
×
1293
                            ]
×
1294
                        ]
×
1295
                    ]
×
1296
                ]
×
1297
            ],
×
1298
            'max_tokens' => 10
×
1299
        ];
×
1300

1301
        $ch = curl_init(self::OPENAI_API_URL);
×
1302
        curl_setopt_array($ch, [
×
1303
            CURLOPT_RETURNTRANSFER => TRUE,
×
1304
            CURLOPT_POST => TRUE,
×
1305
            CURLOPT_POSTFIELDS => json_encode($payload),
×
1306
            CURLOPT_HTTPHEADER => [
×
1307
                'Content-Type: application/json',
×
1308
                'Authorization: Bearer ' . OPENAI_API_KEY
×
1309
            ],
×
1310
            CURLOPT_TIMEOUT => 30
×
1311
        ]);
×
1312

1313
        $response = curl_exec($ch);
×
1314
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
1315
        curl_close($ch);
×
1316

1317
        if ($httpCode !== 200 || !$response) {
×
1318
            error_log("OpenAI vision check failed for '$itemName': HTTP $httpCode");
×
1319
            return NULL;
×
1320
        }
1321

1322
        $data = json_decode($response, TRUE);
×
1323

1324
        if (!isset($data['choices'][0]['message']['content'])) {
×
1325
            error_log("OpenAI vision check failed for '$itemName': unexpected response format");
×
1326
            return NULL;
×
1327
        }
1328

1329
        $answer = strtoupper(trim($data['choices'][0]['message']['content']));
×
1330
        $hasPeople = strpos($answer, 'YES') !== FALSE;
×
1331

1332
        if ($hasPeople) {
×
1333
            error_log("OpenAI safety check: people detected in image for '$itemName'");
×
1334
        }
1335

1336
        return $hasPeople;
×
1337
    }
1338
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc