Files
boc/quixzoom-capture-pipeline/zoomer-onboarding/onboarding.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

621 lines
19 KiB
JavaScript

/**
* QUIXZOOM Zoomer Onboarding & Certification System
*
* 4-nivå certifiering:
* 1. Capture Basics (10 min)
* 2. Quality Test (20 objekt)
* 3. Guided Missions (AI-hjälp i realtid)
* 4. Trusted Zoomer (högre ersättning, avancerade uppdrag)
*
* Zoomer Quality Score:
* - GPS-kvalitet
* - Bildkvalitet
* - AI-korrigeringar
* - Godkända observationer
* - Bekräftelser från andra Zoomers
* - Svarstid
* - Fullföljandegrad
*/
class ZoomerOnboarding {
constructor(config = {}) {
this.config = {
levels: [
{ id: 1, name: 'Capture Basics', duration: 10 }, // 10 minuter
{ id: 2, name: 'Quality Test', duration: 30 }, // 30 minuter
{ id: 3, name: 'Guided Missions', duration: 60 }, // 1 timme
{ id: 4, name: 'Trusted Zoomer', duration: Infinity }, // Kontinuerlig
],
qualityThresholds: {
gpsAccuracy: 5, // meters
imageBlur: 0.3, // 0-1
imageExposure: 0.7, // 0-1
minObservations: 20, // för Quality Test
minApprovalRate: 0.8, // 80%
},
...config,
};
this.zoomers = new Map();
this.certifications = new Map();
}
/**
* ============================================================
* ONBOARDING FLOW
* ============================================================
*/
async onboardZoomer(zoomerId, profile) {
console.log(`[Onboarding] Starting onboarding for ${zoomerId}`);
const zoomer = {
id: zoomerId,
profile,
level: 0,
status: 'onboarding',
startedAt: new Date().toISOString(),
completedAt: null,
scores: {
gps: [],
image: [],
aiCorrections: 0,
approvedObservations: 0,
totalObservations: 0,
confirmations: 0,
responseTime: [],
completionRate: [],
},
};
this.zoomers.set(zoomerId, zoomer);
// Start Level 1
await this.startLevel1(zoomerId);
return zoomer;
}
async startLevel1(zoomerId) {
console.log(`[Onboarding] Level 1: Capture Basics for ${zoomerId}`);
// Store training data
const training = {
title: 'Capture Basics',
duration: 10, // minutes
modules: [
{
title: 'Håll telefonen rätt',
content: 'Håll telefonen i brösthöjd, lutad 15° nedåt. Använd båda händerna för stabilitet.',
video: 'hold_phone.mp4',
},
{
title: 'Gånghastighet',
content: 'Gå i normal takt (5 km/h). Stanna vid varje objekt i 2-3 sekunder.',
video: 'walking_pace.mp4',
},
{
title: 'Undvik motljus',
content: 'Stå med solen bakom dig. Om motljus är oundvikligt, använd HDR.',
video: 'avoid_backlight.mp4',
},
{
title: 'Stabil video',
content: 'Andas ut innan du trycker på knappen. Använd telefonens stabilisering.',
video: 'stable_video.mp4',
},
{
title: 'Flera vinklar',
content: 'Dokumentera varje objekt från 2-3 vinklar. Gå runt objektet om möjligt.',
video: 'multiple_angles.mp4',
},
],
quiz: [
{
question: 'Vilken är optimal gånghastighet?',
options: ['3 km/h', '5 km/h', '8 km/h', '10 km/h'],
correct: 1,
},
{
question: 'Hur länge ska du stanna vid varje objekt?',
options: ['1 sekund', '2-3 sekunder', '5 sekunder', '10 sekunder'],
correct: 1,
},
{
question: 'Vad gör du vid motljus?',
options: ['Tar bilden ändå', 'Står med solen bakom dig', 'Använder blixt', 'Väntar på moln'],
correct: 1,
},
],
};
// Store for retrieval
if (!this.certifications.has(zoomerId)) {
this.certifications.set(zoomerId, {});
}
this.certifications.get(zoomerId).level1 = training;
return training;
}
async completeLevel1(zoomerId, quizResults) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) throw new Error('Zoomer not found');
const passed = quizResults.filter(r => r.correct).length / quizResults.length >= 0.8;
if (passed) {
zoomer.level = 1;
console.log(`[Onboarding] ${zoomerId} passed Level 1`);
await this.startLevel2(zoomerId);
} else {
console.log(`[Onboarding] ${zoomerId} failed Level 1, retry required`);
}
return { passed, level: zoomer.level };
}
async startLevel2(zoomerId) {
console.log(`[Onboarding] Level 2: Quality Test for ${zoomerId}`);
const testMission = {
title: 'Quality Test',
description: 'Dokumentera 20 objekt i ditt område. AI:n bedömer kvaliteten.',
objectives: 20,
criteria: {
gpsAccuracy: '< 5m',
imageBlur: '< 0.3',
imageExposure: '> 0.7',
coverage: '2+ vinklar per objekt',
},
timeLimit: 30, // minutes
};
return testMission;
}
async evaluateLevel2(zoomerId, observations) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) throw new Error('Zoomer not found');
const metrics = this.calculateQualityMetrics(observations);
const passed =
metrics.gpsAccuracy <= this.config.qualityThresholds.gpsAccuracy &&
metrics.imageBlur <= this.config.qualityThresholds.imageBlur &&
metrics.imageExposure >= this.config.qualityThresholds.imageExposure &&
observations.length >= this.config.qualityThresholds.minObservations;
if (passed) {
zoomer.level = 2;
console.log(`[Onboarding] ${zoomerId} passed Level 2`);
await this.startLevel3(zoomerId);
} else {
console.log(`[Onboarding] ${zoomerId} failed Level 2`);
console.log(` GPS: ${metrics.gpsAccuracy}m (target: <5m)`);
console.log(` Blur: ${metrics.imageBlur} (target: <0.3)`);
console.log(` Exposure: ${metrics.imageExposure} (target: >0.7)`);
console.log(` Observations: ${observations.length} (target: 20+)`);
}
return { passed, metrics, level: zoomer.level };
}
calculateQualityMetrics(observations) {
const gpsAccuracies = observations.map(o => o.location?.accuracy || 999);
const blurs = observations.map(o => o.quality?.blur || 1);
const exposures = observations.map(o => o.quality?.exposure || 0);
return {
gpsAccuracy: gpsAccuracies.reduce((a, b) => a + b, 0) / gpsAccuracies.length,
imageBlur: blurs.reduce((a, b) => a + b, 0) / blurs.length,
imageExposure: exposures.reduce((a, b) => a + b, 0) / exposures.length,
totalObservations: observations.length,
};
}
async startLevel3(zoomerId) {
console.log(`[Onboarding] Level 3: Guided Missions for ${zoomerId}`);
const guidedMission = {
title: 'Guided Mission',
description: 'AI:n guidar dig i realtid för optimal datainsamling.',
features: [
'Real-time feedback',
'AI suggestions',
'Quality warnings',
'Auto-adjust missions',
],
examples: [
{ trigger: 'distance > 5m', instruction: 'Gå två meter närmare' },
{ trigger: 'occlusion > 0.5', instruction: 'Objektet är delvis skymt, gå runt det' },
{ trigger: 'blur > 0.3', instruction: 'Stå stilla innan du tar bilden' },
{ trigger: 'exposure < 0.5', instruction: 'Vänta tills en bil passerat' },
{ trigger: 'angle < 2', instruction: 'Ta en bild från höger' },
],
};
return guidedMission;
}
async completeLevel3(zoomerId, missionResults) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) throw new Error('Zoomer not found');
const passed = missionResults.completionRate >= 0.8 &&
missionResults.avgQuality >= 0.8;
if (passed) {
zoomer.level = 3;
zoomer.status = 'active';
console.log(`[Onboarding] ${zoomerId} passed Level 3 - Active Zoomer`);
} else {
console.log(`[Onboarding] ${zoomerId} needs more guided missions`);
}
return { passed, level: zoomer.level };
}
async promoteToTrusted(zoomerId) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) throw new Error('Zoomer not found');
const score = this.calculateZoomerQualityScore(zoomerId);
if (score.overall >= 0.8 && zoomer.level >= 3) {
zoomer.level = 4;
zoomer.status = 'trusted';
console.log(`[Onboarding] ${zoomerId} promoted to Trusted Zoomer`);
return {
promoted: true,
benefits: [
'Higher compensation (+50%)',
'Advanced missions',
'Larger geographic area',
'Can verify other observations',
'Regional Coordinator eligibility',
],
};
}
return { promoted: false, score: score.overall };
}
/**
* ============================================================
* ZOOMER QUALITY SCORE
* ============================================================
*/
calculateZoomerQualityScore(zoomerId) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) return null;
const scores = zoomer.scores;
// GPS Quality (20%)
const avgGps = scores.gps.length > 0
? scores.gps.reduce((a, b) => a + b, 0) / scores.gps.length
: 999;
const gpsScore = Math.max(0, 1 - (avgGps / 10)); // 0m = 1.0, 10m = 0.0
// Image Quality (20%)
const avgImage = scores.image.length > 0
? scores.image.reduce((a, b) => a + b, 0) / scores.image.length
: 0;
const imageScore = avgImage;
// AI Corrections (15%) - lower is better
const totalObs = scores.totalObservations || 1;
const correctionRate = scores.aiCorrections / totalObs;
const correctionScore = Math.max(0, 1 - correctionRate);
// Approval Rate (20%)
const approvalRate = scores.totalObservations > 0
? scores.approvedObservations / scores.totalObservations
: 0;
// Confirmations (10%)
const confirmationScore = Math.min(1, scores.confirmations / 10);
// Response Time (10%)
const avgResponse = scores.responseTime.length > 0
? scores.responseTime.reduce((a, b) => a + b, 0) / scores.responseTime.length
: 3600;
const responseScore = Math.max(0, 1 - (avgResponse / 3600)); // <1h = 1.0
// Completion Rate (5%)
const avgCompletion = scores.completionRate.length > 0
? scores.completionRate.reduce((a, b) => a + b, 0) / scores.completionRate.length
: 0;
const overall =
gpsScore * 0.20 +
imageScore * 0.20 +
correctionScore * 0.15 +
approvalRate * 0.20 +
confirmationScore * 0.10 +
responseScore * 0.10 +
avgCompletion * 0.05;
return {
overall: Math.round(overall * 100) / 100,
breakdown: {
gps: Math.round(gpsScore * 100) / 100,
image: Math.round(imageScore * 100) / 100,
corrections: Math.round(correctionScore * 100) / 100,
approval: Math.round(approvalRate * 100) / 100,
confirmations: Math.round(confirmationScore * 100) / 100,
response: Math.round(responseScore * 100) / 100,
completion: Math.round(avgCompletion * 100) / 100,
},
level: zoomer.level,
status: zoomer.status,
};
}
updateZoomerScore(zoomerId, observation) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) return;
const scores = zoomer.scores;
// GPS
if (observation.location?.accuracy) {
scores.gps.push(observation.location.accuracy);
}
// Image quality
if (observation.quality?.overall) {
scores.image.push(observation.quality.overall);
}
// AI corrections
if (observation.aiCorrections) {
scores.aiCorrections += observation.aiCorrections;
}
// Approval
scores.totalObservations++;
if (observation.approved) {
scores.approvedObservations++;
}
// Response time
if (observation.responseTime) {
scores.responseTime.push(observation.responseTime);
}
// Completion
if (observation.missionCompleted !== undefined) {
scores.completionRate.push(observation.missionCompleted ? 1 : 0);
}
}
/**
* ============================================================
* MISSION ASSIGNMENT
* ============================================================
*/
assignMission(zoomerId, mission) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) return null;
const score = this.calculateZoomerQualityScore(zoomerId);
// Match zoomer to mission based on quality and level
const suitability = this.calculateSuitability(score, mission);
return {
zoomerId,
mission,
suitability,
recommended: suitability > 0.8,
};
}
calculateSuitability(score, mission) {
if (!score) return 0;
let suitability = score.overall;
// Adjust based on mission complexity
if (mission.complexity === 'high' && score.overall < 0.8) {
suitability *= 0.5;
}
if (mission.timeOfDay === 'night' && score.breakdown.image < 0.7) {
suitability *= 0.7;
}
if (mission.requiresVerification && score.level < 4) {
suitability = 0;
}
return Math.round(suitability * 100) / 100;
}
/**
* ============================================================
* REGIONAL COORDINATOR
* ============================================================
*/
async promoteToRegionalCoordinator(zoomerId, area) {
const zoomer = this.zoomers.get(zoomerId);
if (!zoomer) throw new Error('Zoomer not found');
const score = this.calculateZoomerQualityScore(zoomerId);
if (score.overall >= 0.8 && zoomer.level === 4) {
zoomer.role = 'regional_coordinator';
zoomer.area = area;
console.log(`[Onboarding] ${zoomerId} promoted to Regional Coordinator for ${area}`);
return {
promoted: true,
responsibilities: [
'Quality assurance in area',
'Help train new Zoomers',
'Answer questions',
'Spot-check observations',
'Report issues',
],
compensation: {
base: 1.5, // 50% bonus
perVerification: 5, // kr per verification
},
};
}
return { promoted: false, score: score.overall };
}
/**
* ============================================================
* STATS
* ============================================================
*/
getStats() {
const zoomers = Array.from(this.zoomers.values());
return {
total: zoomers.length,
byLevel: {
0: zoomers.filter(z => z.level === 0).length,
1: zoomers.filter(z => z.level === 1).length,
2: zoomers.filter(z => z.level === 2).length,
3: zoomers.filter(z => z.level === 3).length,
4: zoomers.filter(z => z.level === 4).length,
},
byStatus: {
onboarding: zoomers.filter(z => z.status === 'onboarding').length,
active: zoomers.filter(z => z.status === 'active').length,
trusted: zoomers.filter(z => z.status === 'trusted').length,
},
regionalCoordinators: zoomers.filter(z => z.role === 'regional_coordinator').length,
};
}
}
module.exports = ZoomerOnboarding;
// Demo
async function runDemo() {
const onboarding = new ZoomerOnboarding();
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ ZOOMER ONBOARDING & CERTIFICATION SYSTEM ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
// Onboard a new Zoomer
const zoomerId = 'zoomer_bangkok_001';
onboarding.onboardZoomer(zoomerId, {
name: 'Somchai',
email: 'somchai@example.com',
phone: '+66-81-234-5678',
location: 'Bangkok',
});
console.log('\n=== LEVEL 1: CAPTURE BASICS ===');
const level1 = await onboarding.startLevel1(zoomerId);
console.log(`Training: ${level1.title}`);
console.log(`Modules: ${level1.modules.length}`);
console.log(`Quiz questions: ${level1.quiz.length}`);
// Simulate passing Level 1
const quizResults = level1.quiz.map((q, i) => ({ question: i, correct: true }));
await onboarding.completeLevel1(zoomerId, quizResults);
console.log('\n=== LEVEL 2: QUALITY TEST ===');
const level2 = await onboarding.startLevel2(zoomerId);
console.log(`Mission: ${level2.title}`);
console.log(`Objectives: ${level2.objectives} objects`);
// Simulate observations
const observations = Array.from({ length: 22 }, (_, i) => ({
id: `obs_${i}`,
location: { accuracy: 3 + Math.random() * 2 },
quality: {
blur: Math.random() * 0.2,
exposure: 0.8 + Math.random() * 0.2,
overall: 0.85,
},
}));
await onboarding.evaluateLevel2(zoomerId, observations);
console.log('\n=== LEVEL 3: GUIDED MISSIONS ===');
const level3 = await onboarding.startLevel3(zoomerId);
console.log(`Features: ${level3.features.join(', ')}`);
// Simulate completing Level 3
await onboarding.completeLevel3(zoomerId, {
completionRate: 0.9,
avgQuality: 0.85,
});
console.log('\n=== ZOOMER QUALITY SCORE ===');
// Add some scores
const zoomer = onboarding.zoomers.get(zoomerId);
zoomer.scores.gps = [3, 4, 3, 5, 4, 3, 4, 5, 3, 4];
zoomer.scores.image = [0.9, 0.85, 0.9, 0.8, 0.9, 0.85, 0.9, 0.9, 0.85, 0.9];
zoomer.scores.aiCorrections = 2;
zoomer.scores.approvedObservations = 45;
zoomer.scores.totalObservations = 50;
zoomer.scores.confirmations = 8;
zoomer.scores.responseTime = [300, 450, 200, 600, 350, 400, 250, 500, 300, 350];
zoomer.scores.completionRate = [1, 1, 1, 1, 0, 1, 1, 1, 1, 1];
const score = onboarding.calculateZoomerQualityScore(zoomerId);
console.log(`Overall: ${score.overall}`);
console.log(`Breakdown:`);
for (const [key, value] of Object.entries(score.breakdown)) {
console.log(` ${key}: ${value}`);
}
console.log('\n=== PROMOTE TO TRUSTED ===');
const promotion = onboarding.promoteToTrusted(zoomerId);
console.log(`Promoted: ${promotion.promoted}`);
if (promotion.promoted) {
console.log('Benefits:');
promotion.benefits.forEach(b => console.log(` - ${b}`));
}
console.log('\n=== MISSION ASSIGNMENT ===');
const mission = {
id: 'mission_001',
type: 'night_inventory',
complexity: 'high',
timeOfDay: 'night',
requiresVerification: true,
};
const assignment = onboarding.assignMission(zoomerId, mission);
console.log(`Mission: ${mission.type}`);
console.log(`Suitability: ${assignment.suitability}`);
console.log(`Recommended: ${assignment.recommended}`);
console.log('\n=== REGIONAL COORDINATOR ===');
const rc = onboarding.promoteToRegionalCoordinator(zoomerId, 'Sukhumvit');
console.log(`Promoted: ${rc.promoted}`);
if (rc.promoted) {
console.log('Responsibilities:');
rc.responsibilities.forEach(r => console.log(` - ${r}`));
}
console.log('\n=== STATS ===');
console.log(JSON.stringify(onboarding.getStats(), null, 2));
console.log('\n✅ Zoomer Onboarding System ready!');
}
if (require.main === module) {
runDemo().catch(console.error);
}