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

Freegle / iznik-server / #2589

10 Mar 2026 09:13PM UTC coverage: 85.748%. First build
#2589

push

edwh
Fix Pollinations HTTP error detection missing 429 and other errors

buildHttpContext() was not setting ignore_errors => TRUE, so
file_get_contents suppressed response headers on error responses.
This caused HTTP 429 rate limiting to be misdetected as "no data"
individual failures instead of triggering batch abort and backoff.

Also adds detection of other HTTP errors (500, etc.) in both
fetchImage() and fetchBatch() to avoid treating error response
bodies as image data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

0 of 13 new or added lines in 1 file covered. (0.0%)

25582 of 29834 relevant lines covered (85.75%)

30.61 hits per line

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

9.62
/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' => [
×
NEW
745
                'timeout' => $timeout,
×
NEW
746
                'ignore_errors' => TRUE
×
747
            ]
×
748
        ]);
×
749
    }
750

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

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

768
        return $url;
×
769
    }
770

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

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

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

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

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

NEW
799
                if (preg_match('/^HTTP\/\d+\.?\d*\s+(\d+)/', $header, $matches)) {
×
NEW
800
                    $httpCode = (int)$matches[1];
×
NEW
801
                    if ($httpCode >= 400) {
×
NEW
802
                        error_log("Pollinations HTTP $httpCode for: " . $prompt);
×
NEW
803
                        return FALSE;
×
804
                    }
805
                }
806
            }
807
        }
808

809
        if (!$data || strlen($data) == 0) {
×
810
            error_log("Pollinations failed to return data for: " . $prompt);
×
811
            return FALSE;
×
812
        }
813

814
        # Compute hash of received image.
815
        $hash = md5($data);
×
816

817
        # Check 1: In-memory cache (same process).
818
        if (isset(self::$seenHashes[$hash]) && self::$seenHashes[$hash] !== $prompt) {
×
819
            error_log("Pollinations rate limited (in-memory duplicate) for: " . $prompt .
×
820
                      " (same image as: " . self::$seenHashes[$hash] . ")");
×
821
            # Clean up the recently added entry that we now know is rate-limited.
822
            self::cleanupRateLimitedHash($hash);
×
823
            return FALSE;
×
824
        }
825

826
        # Check 2: File-based cache (across processes).
827
        $existingPrompt = self::checkFileCache($hash, $prompt);
×
828
        if ($existingPrompt !== FALSE) {
×
829
            error_log("Pollinations rate limited (file cache duplicate) for: " . $prompt .
×
830
                      " (same image as: " . $existingPrompt . ")");
×
831
            # Clean up recently added entries with this hash.
832
            self::cleanupRateLimitedHash($hash);
×
833
            return FALSE;
×
834
        }
835

836
        # Check 3: Database (historical data).
837
        if ($dbhr) {
×
838
            $existing = $dbhr->preQuery(
×
839
                "SELECT name FROM ai_images WHERE imagehash = ? AND name != ? LIMIT 1",
×
840
                [$hash, $prompt]
×
841
            );
×
842

843
            if (count($existing) > 0) {
×
844
                error_log("Pollinations rate limited (DB duplicate) for: " . $prompt .
×
845
                          " (same image as: " . $existing[0]['name'] . ")");
×
846
                # Also add to file cache to speed up future checks.
847
                self::addToFileCache($hash, $existing[0]['name']);
×
848
                return FALSE;
×
849
            }
850
        }
851

852
        # All checks passed - track this hash.
853
        self::$seenHashes[$hash] = $prompt;
×
854
        self::addToFileCache($hash, $prompt);
×
855

856
        return $data;
×
857
    }
858

859
    /**
860
     * Clean up recently added ai_images entries with a rate-limited hash.
861
     * Only removes entries added in the last hour to avoid removing legitimate old entries.
862
     * @param string $hash The rate-limited image hash.
863
     */
864
    private static function cleanupRateLimitedHash($hash) {
865
        global $dbhm;
866

867
        if (!$dbhm) {
×
868
            return;
×
869
        }
870

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

877
        if ($deleted) {
×
878
            $count = $dbhm->rowsAffected();
×
879
            if ($count > 0) {
×
880
                error_log("Cleaned up $count recent ai_images entries with rate-limited hash: $hash");
×
881
            }
882
        }
883

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

890
        $orphaned = $dbhm->preExec(
×
891
            "DELETE ma FROM messages_attachments ma
×
892
             LEFT JOIN ai_images ai ON ma.externaluid = ai.externaluid
893
             WHERE ma.externaluid LIKE 'freegletusd-%'
894
             AND JSON_EXTRACT(ma.externalmods, '$.ai') = TRUE
895
             AND ai.id IS NULL
896
             AND ma.id > ?",
×
897
            [$minIdVal]
×
898
        );
×
899

900
        if ($orphaned) {
×
901
            $count = $dbhm->rowsAffected();
×
902
            if ($count > 0) {
×
903
                error_log("Cleaned up $count orphaned message attachments");
×
904
            }
905
        }
906
    }
907

908
    /**
909
     * Build a prompt for a message illustration.
910
     * @param string $itemName The item name.
911
     * @return string The full prompt.
912
     */
913
    public static function buildMessagePrompt($itemName) {
914
        # Prompt injection defense.
915
        $cleanName = str_replace('CRITICAL:', '', $itemName);
9✔
916
        $cleanName = str_replace('Draw only', '', $cleanName);
9✔
917

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

927
    /**
928
     * Map a job title to a canonical job category using pure PHP keyword matching.
929
     * No API calls. Returns NULL for unmapped titles.
930
     *
931
     * @param string $title The raw job title.
932
     * @return string|null The canonical job title, or NULL if no match.
933
     */
934
    public static function canonicalJobTitle($title) {
935
        if (empty($title)) {
23✔
936
            return NULL;
1✔
937
        }
938

939
        # Clean: lowercase, strip location suffixes, ref numbers, trim.
940
        $clean = strtolower(trim($title));
22✔
941

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

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

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

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

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

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

962
        $clean = trim($clean);
22✔
963

964
        # First: check if cleaned title exactly matches a canonical title (case-insensitive).
965
        foreach (self::CANONICAL_JOBS as $canonical => $object) {
22✔
966
            if (strtolower($canonical) === $clean) {
22✔
967
                return $canonical;
6✔
968
            }
969
        }
970

971
        # Second: keyword pattern matching.
972
        foreach (self::JOB_KEYWORD_MAP as $entry) {
17✔
973
            $patterns = $entry[0];
17✔
974
            $canonical = $entry[1];
17✔
975

976
            # All keywords in the pattern must be present in the cleaned title.
977
            $allMatch = TRUE;
17✔
978
            foreach ($patterns as $keyword) {
17✔
979
                if (strpos($clean, strtolower($keyword)) === FALSE) {
17✔
980
                    $allMatch = FALSE;
17✔
981
                    break;
17✔
982
                }
983
            }
984

985
            if ($allMatch) {
17✔
986
                return $canonical;
16✔
987
            }
988
        }
989

990
        return NULL;
1✔
991
    }
992

993
    /**
994
     * Use GPT to map a job title to an iconic inanimate object/tool for that job.
995
     * This avoids generating images of people by never mentioning the profession
996
     * in the image prompt.
997
     *
998
     * @param string $jobTitle The job title.
999
     * @return string|false The object name, or FALSE on failure.
1000
     */
1001
    public static function objectForJob($jobTitle) {
1002
        if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
×
1003
            return FALSE;
×
1004
        }
1005

1006
        $cleanName = str_replace('CRITICAL:', '', $jobTitle);
×
1007
        $cleanName = str_replace('Draw only', '', $cleanName);
×
1008

1009
        $payload = [
×
1010
            'model' => 'gpt-4o-mini',
×
1011
            'messages' => [
×
1012
                [
×
1013
                    'role' => 'system',
×
1014
                    '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.'
×
1015
                ],
×
1016
                [
×
1017
                    'role' => 'user',
×
1018
                    'content' => $cleanName
×
1019
                ]
×
1020
            ],
×
1021
            'max_tokens' => 20
×
1022
        ];
×
1023

1024
        $ch = curl_init(self::OPENAI_API_URL);
×
1025
        curl_setopt_array($ch, [
×
1026
            CURLOPT_RETURNTRANSFER => TRUE,
×
1027
            CURLOPT_POST => TRUE,
×
1028
            CURLOPT_POSTFIELDS => json_encode($payload),
×
1029
            CURLOPT_HTTPHEADER => [
×
1030
                'Content-Type: application/json',
×
1031
                'Authorization: Bearer ' . OPENAI_API_KEY
×
1032
            ],
×
1033
            CURLOPT_TIMEOUT => 15
×
1034
        ]);
×
1035

1036
        $response = curl_exec($ch);
×
1037
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
1038
        curl_close($ch);
×
1039

1040
        if ($httpCode !== 200 || !$response) {
×
1041
            error_log("objectForJob failed for '$jobTitle': HTTP $httpCode");
×
1042
            return FALSE;
×
1043
        }
1044

1045
        $data = json_decode($response, TRUE);
×
1046

1047
        if (!isset($data['choices'][0]['message']['content'])) {
×
1048
            error_log("objectForJob failed for '$jobTitle': unexpected response");
×
1049
            return FALSE;
×
1050
        }
1051

1052
        return trim($data['choices'][0]['message']['content']);
×
1053
    }
1054

1055
    /**
1056
     * Build a prompt for a job illustration.
1057
     * Uses the same object-focused prompt as messages to avoid generating people.
1058
     * @param string $objectName The object name (from objectForJob).
1059
     * @return string The full prompt.
1060
     */
1061
    public static function buildJobPrompt($objectName) {
1062
        return self::buildMessagePrompt($objectName);
3✔
1063
    }
1064

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

1081
        $results = [];
×
1082
        $failed = [];
×
1083
        $batchHashes = [];
×
1084

1085
        foreach ($items as $item) {
×
1086
            $name = $item['name'];
×
1087
            $prompt = $item['prompt'];
×
1088
            $width = $item['width'] ?? 640;
×
1089
            $height = $item['height'] ?? 480;
×
1090

1091
            $url = self::buildImageUrl($prompt, $width, $height);
×
1092

1093
            $ctx = self::buildHttpContext($timeout);
×
1094

1095
            error_log("Batch fetching image for: $name");
×
1096
            $data = @file_get_contents($url, FALSE, $ctx);
×
1097

1098
            # Check HTTP status code.
1099
            if (isset($http_response_header)) {
×
1100
                foreach ($http_response_header as $header) {
×
1101
                    if (preg_match('/^HTTP\/\d+\.?\d*\s+429/', $header)) {
×
1102
                        error_log("Pollinations rate limited (HTTP 429) for batch item: $name");
×
1103
                        return FALSE;
×
1104
                    }
1105

NEW
1106
                    if (preg_match('/^HTTP\/\d+\.?\d*\s+(\d+)/', $header, $matches)) {
×
NEW
1107
                        $httpCode = (int)$matches[1];
×
NEW
1108
                        if ($httpCode >= 400) {
×
NEW
1109
                            error_log("Pollinations HTTP $httpCode for batch item: $name (skipping)");
×
NEW
1110
                            $failed[$name] = TRUE;
×
NEW
1111
                            continue 2;
×
1112
                        }
1113
                    }
1114
                }
1115
            }
1116

1117
            # No data returned - this is an individual failure, NOT rate-limiting.
1118
            # Skip this item but continue with others.
1119
            if (!$data || strlen($data) == 0) {
×
1120
                error_log("Pollinations failed to return data for batch item: $name (skipping)");
×
1121
                $failed[$name] = TRUE;
×
1122
                continue;
×
1123
            }
1124

1125
            $hash = md5($data);
×
1126

1127
            # Check if this hash already appeared in this batch for a different item.
1128
            # This IS rate-limiting - fail entire batch.
1129
            if (isset($batchHashes[$hash]) && $batchHashes[$hash] !== $name) {
×
1130
                error_log("Pollinations rate limited (batch duplicate) for: $name (same as: " . $batchHashes[$hash] . ")");
×
1131
                # Clean up any recently added entries with this hash.
1132
                self::cleanupRateLimitedHash($hash);
×
1133
                return FALSE;
×
1134
            }
1135

1136
            # Check file cache - this IS rate-limiting.
1137
            $existingPrompt = self::checkFileCache($hash, $name);
×
1138
            if ($existingPrompt !== FALSE) {
×
1139
                error_log("Pollinations rate limited (file cache) for batch item: $name (same as: $existingPrompt)");
×
1140
                self::cleanupRateLimitedHash($hash);
×
1141
                return FALSE;
×
1142
            }
1143

1144
            # Check database for historical duplicates - this IS rate-limiting.
1145
            global $dbhr;
1146
            if ($dbhr) {
×
1147
                $existing = $dbhr->preQuery(
×
1148
                    "SELECT name FROM ai_images WHERE imagehash = ? AND name != ? LIMIT 1",
×
1149
                    [$hash, $name]
×
1150
                );
×
1151

1152
                if (count($existing) > 0) {
×
1153
                    error_log("Pollinations rate limited (DB) for batch item: $name (same as: " . $existing[0]['name'] . ")");
×
1154
                    self::addToFileCache($hash, $existing[0]['name']);
×
1155
                    self::cleanupRateLimitedHash($hash);
×
1156
                    return FALSE;
×
1157
                }
1158
            }
1159

1160
            # Safety check: verify image doesn't contain people.
1161
            $hasPeople = self::checkForPeople($data, $name);
×
1162
            if ($hasPeople === TRUE) {
×
1163
                error_log("Rejecting image for '$name' - contains people");
×
1164
                $failed[$name] = TRUE;
×
1165
                continue;
×
1166
            }
1167

1168
            $batchHashes[$hash] = $name;
×
1169
            $results[] = [
×
1170
                'name' => $name,
×
1171
                'data' => $data,
×
1172
                'hash' => $hash,
×
1173
                'msgid' => $item['msgid'] ?? NULL,
×
1174
                'jobid' => $item['jobid'] ?? NULL
×
1175
            ];
×
1176

1177
            # Small delay between requests.
1178
            sleep(1);
×
1179
        }
1180

1181
        # Add successful images to caches.
1182
        foreach ($results as $result) {
×
1183
            self::$seenHashes[$result['hash']] = $result['name'];
×
1184
            self::addToFileCache($result['hash'], $result['name']);
×
1185
        }
1186

1187
        return ['results' => $results, 'failed' => $failed];
×
1188
    }
1189

1190
    /**
1191
     * Cache an image in ai_images table.
1192
     * @param string $name The item/job name.
1193
     * @param string $uid The externaluid.
1194
     * @param string $hash Image hash.
1195
     */
1196
    public static function cacheImage($name, $uid, $hash) {
1197
        global $dbhm;
1198

1199
        if ($dbhm) {
×
1200
            $dbhm->preExec(
×
1201
                "INSERT INTO ai_images (name, externaluid, imagehash) VALUES (?, ?, ?)
×
1202
                 ON DUPLICATE KEY UPDATE externaluid = VALUES(externaluid), imagehash = VALUES(imagehash), created = NOW()",
×
1203
                [$name, $uid, $hash]
×
1204
            );
×
1205
        }
1206
    }
1207

1208
    /**
1209
     * Upload image to TUS and cache in ai_images table.
1210
     * Applies duotone filter before uploading to ensure consistent appearance in emails.
1211
     * @param string $name The item/job name.
1212
     * @param string $data Image data.
1213
     * @param string $hash Image hash.
1214
     * @return string|false The externaluid on success, FALSE on failure.
1215
     */
1216
    public static function uploadAndCache($name, $data, $hash) {
1217
        # Apply duotone filter to ensure consistent green/white color scheme.
1218
        # This bakes the effect into the image so it works in emails where CSS filters don't.
1219
        $img = new Image($data);
×
1220
        if ($img->img) {
×
1221
            $img->duotoneGreen();
×
1222
            $data = $img->getData(90);
×
1223

1224
            if (!$data) {
×
1225
                error_log("Failed to apply duotone filter for: $name");
×
1226
                return FALSE;
×
1227
            }
1228
        }
1229

1230
        $t = new Tus();
×
1231
        $tusUrl = $t->upload(NULL, 'image/jpeg', $data);
×
1232

1233
        if (!$tusUrl) {
×
1234
            error_log("Failed to upload image to TUS for: $name");
×
1235
            return FALSE;
×
1236
        }
1237

1238
        $uid = 'freegletusd-' . basename($tusUrl);
×
1239
        self::cacheImage($name, $uid, $hash);
×
1240

1241
        return $uid;
×
1242
    }
1243

1244
    /**
1245
     * Get the hash of image data.
1246
     * @param string $data Image data.
1247
     * @return string MD5 hash.
1248
     */
1249
    public static function getImageHash($data) {
1250
        return md5($data);
3✔
1251
    }
1252

1253
    /**
1254
     * Clear the in-memory hash cache (mainly for testing).
1255
     */
1256
    public static function clearCache() {
1257
        self::$seenHashes = [];
37✔
1258
    }
1259

1260
    /**
1261
     * Clear the file-based hash cache (mainly for testing/maintenance).
1262
     */
1263
    public static function clearFileCache() {
1264
        if (file_exists(self::CACHE_FILE)) {
37✔
1265
            @unlink(self::CACHE_FILE);
×
1266
        }
1267
    }
1268

1269
    /**
1270
     * Check if an image contains people using GPT-4o-mini vision.
1271
     * Only runs if OPENAI_API_KEY is defined in config.
1272
     *
1273
     * @param string $imageData Raw image data (not base64 encoded).
1274
     * @param string $itemName Item name for logging.
1275
     * @return bool|null TRUE if people detected, FALSE if not, NULL if check unavailable/failed.
1276
     */
1277
    public static function checkForPeople($imageData, $itemName = '') {
1278
        # Only run check if OpenAI API key is configured.
1279
        if (!defined('OPENAI_API_KEY') || !OPENAI_API_KEY) {
×
1280
            return NULL;
×
1281
        }
1282

1283
        # Convert image to base64 data URL.
1284
        $base64 = base64_encode($imageData);
×
1285
        $mimeType = 'image/jpeg';
×
1286

1287
        # Try to detect actual mime type.
1288
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
×
1289
        $detected = $finfo->buffer($imageData);
×
1290
        if ($detected && strpos($detected, 'image/') === 0) {
×
1291
            $mimeType = $detected;
×
1292
        }
1293

1294
        $dataUrl = "data:{$mimeType};base64,{$base64}";
×
1295

1296
        $payload = [
×
1297
            'model' => 'gpt-4o-mini',
×
1298
            'messages' => [
×
1299
                [
×
1300
                    'role' => 'user',
×
1301
                    'content' => [
×
1302
                        [
×
1303
                            'type' => 'text',
×
1304
                            'text' => 'Does this image contain any people, human figures, hands, arms, legs, or body parts? Answer only YES or NO.'
×
1305
                        ],
×
1306
                        [
×
1307
                            'type' => 'image_url',
×
1308
                            'image_url' => [
×
1309
                                'url' => $dataUrl,
×
1310
                                'detail' => 'low'
×
1311
                            ]
×
1312
                        ]
×
1313
                    ]
×
1314
                ]
×
1315
            ],
×
1316
            'max_tokens' => 10
×
1317
        ];
×
1318

1319
        $ch = curl_init(self::OPENAI_API_URL);
×
1320
        curl_setopt_array($ch, [
×
1321
            CURLOPT_RETURNTRANSFER => TRUE,
×
1322
            CURLOPT_POST => TRUE,
×
1323
            CURLOPT_POSTFIELDS => json_encode($payload),
×
1324
            CURLOPT_HTTPHEADER => [
×
1325
                'Content-Type: application/json',
×
1326
                'Authorization: Bearer ' . OPENAI_API_KEY
×
1327
            ],
×
1328
            CURLOPT_TIMEOUT => 30
×
1329
        ]);
×
1330

1331
        $response = curl_exec($ch);
×
1332
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
1333
        curl_close($ch);
×
1334

1335
        if ($httpCode !== 200 || !$response) {
×
1336
            error_log("OpenAI vision check failed for '$itemName': HTTP $httpCode");
×
1337
            return NULL;
×
1338
        }
1339

1340
        $data = json_decode($response, TRUE);
×
1341

1342
        if (!isset($data['choices'][0]['message']['content'])) {
×
1343
            error_log("OpenAI vision check failed for '$itemName': unexpected response format");
×
1344
            return NULL;
×
1345
        }
1346

1347
        $answer = strtoupper(trim($data['choices'][0]['message']['content']));
×
1348
        $hasPeople = strpos($answer, 'YES') !== FALSE;
×
1349

1350
        if ($hasPeople) {
×
1351
            error_log("OpenAI safety check: people detected in image for '$itemName'");
×
1352
        }
1353

1354
        return $hasPeople;
×
1355
    }
1356
}
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