/** * 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); }