Spaces:
Running
Running
Download api/v2/_services/youtubeTutorialService.ts from Sarvesh213/getcert-backend: direct link, hf CLI and curl.
- Browser
- Download file 45.3 kB
-
https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/api/v2/_services/youtubeTutorialService.ts
- Command line
-
hf download hf://spaces/Sarvesh213/getcert-backend/api/v2/_services/youtubeTutorialService.ts
-
curl -L -o youtubeTutorialService.ts https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/api/v2/_services/youtubeTutorialService.ts
45.3 kB
| import { backendLogger as logger } from '../_shared/backendLogger.js'; | |
| import { scoreCandidateWithKev, evaluateYouTubeCurriculumWithKev, isKevAvailable } from './kevService.js'; | |
| import { callGemma, extractJsonPayload } from './llm/client.js'; | |
| // βββ Interfaces & Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export interface YouTubeVideoMetadata { | |
| videoId: string; | |
| url: string; | |
| title: string; | |
| channelTitle: string; | |
| channelId?: string; | |
| description: string; | |
| durationSeconds: number; | |
| durationText: string; | |
| thumbnailUrl: string; | |
| tags: string[]; | |
| hasCaptions: boolean; | |
| transcript?: string; | |
| } | |
| export interface EvidenceEvaluationResult { | |
| hasContentEvidence: boolean; | |
| evidenceScore: number; | |
| chapterCount: number; | |
| technicalTermDensity: number; | |
| rejectionReason?: string; | |
| detectedChapters: Array<{ time: string; title: string }>; | |
| detectedOutlines: string[]; | |
| } | |
| export interface KevDecisionSignals { | |
| validVideo: boolean; | |
| durationValid: boolean; | |
| hasContentEvidence: boolean; | |
| evidenceScore: number; | |
| educational: boolean; | |
| skillBased: boolean; | |
| sufficientDepth: boolean; | |
| assessable: boolean; | |
| confidence: number; | |
| decision: 'accept' | 'reject'; | |
| rejectionReason?: string; | |
| } | |
| export interface LearningOutcomes { | |
| domain: string; | |
| skillTags: string[]; | |
| level: 'Beginner' | 'Intermediate' | 'Advanced' | 'Comprehensive'; | |
| outcomes: string[]; | |
| } | |
| export interface CurriculumModuleItem { | |
| id?: string; | |
| title: string; | |
| moduleTitle: string; | |
| topics: string[]; | |
| subtopics: string[]; | |
| } | |
| export interface SynthesizedCertification { | |
| courseTitle: string; | |
| sourceVideoTitle: string; | |
| sourceChannelTitle: string; | |
| domain: string; | |
| level: string; | |
| skillTags: string[]; | |
| learningOutcomes: string[]; | |
| syllabus: CurriculumModuleItem[]; | |
| } | |
| export type QuestionType = 'applied_mcq' | 'scenario_mcq' | 'conceptual_mcq'; | |
| export interface AssessmentQuestionPublic { | |
| id: string; | |
| index: number; | |
| type: QuestionType; | |
| category: string; | |
| question: string; | |
| prompt: string; | |
| options: string[]; | |
| } | |
| export interface AssessmentQuestionPrivate extends AssessmentQuestionPublic { | |
| answerIndex: number; // 0-3 | |
| explanation: string; | |
| } | |
| export interface ClientAssessmentPayload { | |
| versionId: string; | |
| totalQuestions: number; | |
| timeLimitMinutes: number; // 25 | |
| passingScorePercentage: number; // 70 | |
| certificationFeeInr: number; // 59 | |
| questions: AssessmentQuestionPublic[]; | |
| } | |
| export interface ServerAssessmentSnapshot { | |
| snapshotId: string; | |
| versionId: string; | |
| createdAt: string; | |
| source: { | |
| videoId: string; | |
| sourceUrl: string; | |
| originalTitle: string; | |
| channelName: string; | |
| channelId?: string; | |
| durationSeconds: number; | |
| durationText: string; | |
| }; | |
| certification: SynthesizedCertification; | |
| assessment: { | |
| version: string; | |
| timeLimitMinutes: number; | |
| passingScorePercentage: number; | |
| scoringRules: { | |
| totalQuestions: number; | |
| passingThreshold: number; | |
| }; | |
| questions: AssessmentQuestionPrivate[]; | |
| }; | |
| } | |
| // βββ In-Memory Snapshot Cache ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Keeps generated assessment snapshots keyed by snapshotId and videoId | |
| const snapshotCache = new Map<string, ServerAssessmentSnapshot>(); | |
| // In-memory store for active attempt start timestamps: attemptId -> startedAtMs | |
| const activeAttempts = new Map<string, { startedAtMs: number; snapshotId: string; userId?: string }>(); | |
| // βββ 1. Resolve YouTube Video ID βββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function resolveYouTubeVideoId(inputUrl: string): string | null { | |
| if (!inputUrl) return null; | |
| const trimmed = inputUrl.trim(); | |
| // Direct 11-char ID | |
| if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) { | |
| return trimmed; | |
| } | |
| // Standard YouTube URL formats | |
| const patterns = [ | |
| /(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))([\w-]{11})/i, | |
| /youtube\.com\/shorts\/([\w-]{11})/i, | |
| ]; | |
| for (const regex of patterns) { | |
| const match = trimmed.match(regex); | |
| if (match?.[1]) { | |
| return match[1]; | |
| } | |
| } | |
| return null; | |
| } | |
| // βββ 2. Fetch YouTube Metadata (Official YouTube Data API v3) ββββββββββββββββββ | |
| function parseIsoDurationToSeconds(iso: string): number { | |
| const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); | |
| if (!match) return 0; | |
| const hours = parseInt(match[1] || '0', 10); | |
| const minutes = parseInt(match[2] || '0', 10); | |
| const seconds = parseInt(match[3] || '0', 10); | |
| return hours * 3600 + minutes * 60 + seconds; | |
| } | |
| function formatDurationSeconds(seconds: number): string { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = seconds % 60; | |
| if (h > 0) { | |
| return `${h}h ${m}m`; | |
| } | |
| return `${m}m ${s}s`; | |
| } | |
| export async function fetchYouTubeMetadata(videoId: string): Promise<YouTubeVideoMetadata> { | |
| const apiKeys = [ | |
| process.env.YOUTUBE_API_KEY, | |
| process.env.YOUTUBE_DATA_API_KEY, | |
| process.env.VITE_YOUTUBE_API_KEY, | |
| 'AIzaSyC5xeu_sXkc2Y4TgUKvhQdx6OUejcarefo', | |
| ].filter(Boolean) as string[]; | |
| const defaultThumbnail = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; | |
| const watchUrl = `https://www.youtube.com/watch?v=${videoId}`; | |
| // 1. Official YouTube Data API v3 (Tier 1 - fast, zero-block, official Google REST API) | |
| for (const ytApiKey of apiKeys) { | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 4500); | |
| const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${ytApiKey}`; | |
| const res = await fetch(apiUrl, { signal: controller.signal }); | |
| clearTimeout(timeout); | |
| if (res.ok) { | |
| const json = (await res.json()) as any; | |
| const item = json.items?.[0]; | |
| if (item) { | |
| const rawDuration = item.contentDetails?.duration || ''; | |
| const durationSeconds = parseIsoDurationToSeconds(rawDuration); | |
| const snippet = item.snippet || {}; | |
| return { | |
| videoId, | |
| url: watchUrl, | |
| title: snippet.title || 'Untitled Tutorial', | |
| channelTitle: snippet.channelTitle || 'YouTube Creator', | |
| channelId: snippet.channelId, | |
| description: snippet.description || '', | |
| durationSeconds, | |
| durationText: formatDurationSeconds(durationSeconds), | |
| thumbnailUrl: snippet.thumbnails?.maxres?.url || snippet.thumbnails?.high?.url || defaultThumbnail, | |
| tags: Array.isArray(snippet.tags) ? snippet.tags : [], | |
| hasCaptions: item.contentDetails?.caption === 'true', | |
| }; | |
| } | |
| } | |
| } catch (err) { | |
| logger.warn('[YouTube Service] Official Data API key failed, trying fallback:', err); | |
| } | |
| } | |
| // 2. Direct Watch Page Public Metadata Fallback (Tier 2 - used when Data API key is missing or quota exceeded) | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 4000); | |
| const watchRes = await fetch(watchUrl, { | |
| signal: controller.signal, | |
| headers: { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', | |
| 'Accept-Language': 'en-US,en;q=0.9', | |
| 'Referer': 'https://www.google.com/', | |
| 'Cookie': 'SOCS=CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjMwODI5LjA4X3AwGgJlbhiA-bCmBg; CONSENT=PENDING+999;', | |
| }, | |
| }); | |
| clearTimeout(timeout); | |
| if (watchRes.ok) { | |
| const html = await watchRes.text(); | |
| // Title | |
| let title = ''; | |
| const titleMatch = | |
| html.match(/<meta\s+name="title"\s+content="([^"]*)"/i) || | |
| html.match(/<title>([^<]*)<\/title>/i) || | |
| html.match(/"title":"([^"]+)"/i); | |
| if (titleMatch) { | |
| title = titleMatch[1].replace(/ - YouTube$/, '').trim(); | |
| } | |
| // Channel / Author | |
| let channelTitle = ''; | |
| const authorMatch = | |
| html.match(/"author":"([^"]+)"/i) || | |
| html.match(/<link\s+itemprop="name"\s+content="([^"]*)"/i); | |
| if (authorMatch) { | |
| channelTitle = authorMatch[1].trim(); | |
| } | |
| // Description with all chapters & timestamps | |
| let description = ''; | |
| const descMatch = html.match(/"shortDescription":"((?:[^"\\]|\\.)*)"/); | |
| if (descMatch) { | |
| try { | |
| description = JSON.parse(`"${descMatch[1]}"`); | |
| } catch { | |
| description = descMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"'); | |
| } | |
| } else { | |
| const metaDesc = html.match(/<meta\s+name="description"\s+content="([^"]*)"/i); | |
| if (metaDesc) { | |
| description = metaDesc[1].trim(); | |
| } | |
| } | |
| // Duration | |
| let durationSeconds = 0; | |
| const lengthMatch = | |
| html.match(/"lengthSeconds":"(\d+)"/) || | |
| html.match(/"approxDurationMs":"(\d+)"/); | |
| if (lengthMatch) { | |
| const num = parseInt(lengthMatch[1], 10); | |
| durationSeconds = num > 100000 ? Math.round(num / 1000) : num; | |
| } | |
| if (title) { | |
| return { | |
| videoId, | |
| url: watchUrl, | |
| title, | |
| channelTitle: channelTitle || 'YouTube Creator', | |
| description, | |
| durationSeconds: durationSeconds || 1800, | |
| durationText: formatDurationSeconds(durationSeconds || 1800), | |
| thumbnailUrl: defaultThumbnail, | |
| tags: [], | |
| hasCaptions: true, | |
| }; | |
| } | |
| } | |
| } catch (watchErr) { | |
| logger.warn('[YouTube Service] Watch page metadata extraction failed, attempting QuickCert Innertube scraper fallback:', watchErr); | |
| } | |
| // 3. QuickCert Innertube Scraper (Tier 3 - youtubei.js zero-quota internal API) | |
| try { | |
| const { Innertube, Log } = await import('youtubei.js'); | |
| Log.setLevel(Log.Level.NONE); | |
| const ytPromise = Innertube.create(); | |
| const ytTimeout = new Promise<never>((_, reject) => | |
| setTimeout(() => reject(new Error('Innertube init timeout')), 3500) | |
| ); | |
| const yt = await Promise.race([ytPromise, ytTimeout]); | |
| const infoPromise = yt.getInfo(videoId); | |
| const infoTimeout = new Promise<never>((_, reject) => | |
| setTimeout(() => reject(new Error('Innertube getInfo timeout')), 4000) | |
| ); | |
| const info = await Promise.race([infoPromise, infoTimeout]); | |
| if (info?.basic_info) { | |
| const basic = info.basic_info; | |
| const title = basic.title || ''; | |
| const channelTitle = basic.author || 'YouTube Creator'; | |
| const description = basic.short_description || ''; | |
| const durationSeconds = typeof basic.duration === 'number' ? basic.duration : 0; | |
| const thumbnailUrl = | |
| Array.isArray(basic.thumbnail) && basic.thumbnail.length > 0 | |
| ? basic.thumbnail[basic.thumbnail.length - 1]?.url || defaultThumbnail | |
| : defaultThumbnail; | |
| // Opportunistic spoken transcript extraction with bounded 2.5s timeout | |
| let transcriptText: string | undefined; | |
| try { | |
| const transcriptPromise = info.getTranscript(); | |
| const timeoutPromise = new Promise<null>((_, reject) => | |
| setTimeout(() => reject(new Error('Transcript timeout')), 2500) | |
| ); | |
| const transcriptData = (await Promise.race([transcriptPromise, timeoutPromise])) as any; | |
| if (transcriptData?.transcript?.content?.body?.initial_segments) { | |
| const segs = transcriptData.transcript.content.body.initial_segments; | |
| const joined = segs | |
| .map((s: any) => s?.snippet?.text || '') | |
| .filter(Boolean) | |
| .join(' '); | |
| if (joined.length > 50) { | |
| transcriptText = joined.slice(0, 3000); | |
| } | |
| } | |
| } catch { | |
| // Opportunistic - ignore if unavailable, rate-limited, or timed out | |
| } | |
| if (title) { | |
| return { | |
| videoId, | |
| url: watchUrl, | |
| title, | |
| channelTitle, | |
| channelId: basic.channel_id, | |
| description, | |
| durationSeconds: durationSeconds || 1800, | |
| durationText: formatDurationSeconds(durationSeconds || 1800), | |
| thumbnailUrl, | |
| tags: [], | |
| hasCaptions: true, | |
| transcript: transcriptText, | |
| }; | |
| } | |
| } | |
| } catch (innertubeErr) { | |
| logger.warn('[YouTube Service] QuickCert Innertube scraper fallback failed, attempting oEmbed:', innertubeErr); | |
| } | |
| // 4. Official Compliant oEmbed Fallback (Tier 4) | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 4000); | |
| const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`; | |
| const oembedRes = await fetch(oembedUrl, { | |
| signal: controller.signal, | |
| headers: { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', | |
| }, | |
| }); | |
| clearTimeout(timeout); | |
| if (oembedRes.ok) { | |
| const oembedData = await oembedRes.json(); | |
| return { | |
| videoId, | |
| url: watchUrl, | |
| title: oembedData.title || 'YouTube Technical Tutorial', | |
| channelTitle: oembedData.author_name || 'YouTube Creator', | |
| channelId: undefined, | |
| description: oembedData.title || '', | |
| durationSeconds: 1800, | |
| durationText: '30m', | |
| thumbnailUrl: oembedData.thumbnail_url || defaultThumbnail, | |
| tags: [], | |
| hasCaptions: true, | |
| }; | |
| } | |
| } catch (oembedErr) { | |
| logger.warn('[YouTube Service] oEmbed request failed:', oembedErr); | |
| } | |
| // Default structure if offline or unresolvable | |
| return { | |
| videoId, | |
| url: watchUrl, | |
| title: 'YouTube Technical Tutorial', | |
| channelTitle: 'YouTube Creator', | |
| description: '', | |
| durationSeconds: 1800, | |
| durationText: '30m', | |
| thumbnailUrl: defaultThumbnail, | |
| tags: [], | |
| hasCaptions: true, | |
| }; | |
| } | |
| // βββ 3. Validate Duration (β₯ 15 minutes) ββββββββββββββββββββββββββββββββββββββββ | |
| export function validateDuration(durationSeconds: number): { valid: boolean; durationMinutes: number; reason?: string } { | |
| const durationMinutes = Math.round(durationSeconds / 60); | |
| const MIN_SECONDS = 15 * 60; // 15 minutes = 900 seconds | |
| if (durationSeconds < MIN_SECONDS && durationSeconds > 0) { | |
| return { | |
| valid: false, | |
| durationMinutes, | |
| reason: `Video duration (${durationMinutes} mins) is under 15 minutes. Substantive instructional courses must be at least 15 minutes long to qualify for competency certification.`, | |
| }; | |
| } | |
| return { valid: true, durationMinutes }; | |
| } | |
| // βββ 4. Evaluate Composite Instructional Evidence ββββββββββββββββββββββββββββββ | |
| const TECHNICAL_KEYWORDS = [ | |
| 'react native', 'mobile app', 'expo', 'flutter', 'ios', 'android', | |
| 'python', 'javascript', 'typescript', 'rust', 'c++', 'cpp', 'c#', 'csharp', 'golang', 'go', | |
| 'java', 'kotlin', 'swift', 'react', 'next.js', 'vue', 'angular', 'node', 'express', 'sql', 'database', 'postgres', 'mysql', | |
| 'mongodb', 'docker', 'kubernetes', 'aws', 'api', 'graphql', 'html', 'css', 'tailwind', | |
| 'algorithm', 'data structures', 'function', 'class', 'object', 'struct', 'enum', 'async', | |
| 'promise', 'ownership', 'borrowing', 'mutability', 'cargo', 'git', 'github', 'architecture', | |
| 'backend', 'frontend', 'security', 'test', 'jest', 'linux', 'bash', 'terminal', 'compiler', | |
| 'framework', 'library', 'variable', 'loop', 'array', 'json', 'rest', 'auth', 'jwt', 'schema', | |
| 'query', 'orm', 'state', 'props', 'concurrency', 'error handling' | |
| ]; | |
| export function evaluateInstructionalEvidence(metadata: YouTubeVideoMetadata): EvidenceEvaluationResult { | |
| const desc = metadata.description || ''; | |
| const textToScan = `${metadata.title}\n${metadata.description}\n${metadata.tags.join(' ')}`.toLowerCase(); | |
| // 1. Timestamps / chapters (e.g. 00:00, 1:23, 01:23:45) | |
| const timestampRegex = /(?:^|\n)\s*(?:(\d{1,2}:\d{2}(?::\d{2})?))\s*[-ββ:]?\s*([^\n]+)/g; | |
| const detectedChapters: Array<{ time: string; title: string }> = []; | |
| let match; | |
| while ((match = timestampRegex.exec(desc)) !== null) { | |
| const time = match[1]; | |
| const chapterTitle = match[2].trim(); | |
| if (chapterTitle.length > 2 && !chapterTitle.toLowerCase().includes('http')) { | |
| detectedChapters.push({ time, title: chapterTitle }); | |
| } | |
| } | |
| // 2. Outline headers / bullet points | |
| const outlineRegex = /(?:^|\n)\s*[-*β’\d.]+\s*([A-Z][^\n]{4,})/g; | |
| const detectedOutlines: string[] = []; | |
| while ((match = outlineRegex.exec(desc)) !== null) { | |
| const item = match[1].trim(); | |
| if (!item.toLowerCase().includes('http') && item.length < 80) { | |
| detectedOutlines.push(item); | |
| } | |
| } | |
| // 3. Technical terminology count & density (using strict word boundaries) | |
| let techKeywordMatches = 0; | |
| const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| for (const kw of TECHNICAL_KEYWORDS) { | |
| const escaped = escapeRegExp(kw); | |
| const regex = new RegExp(`\\b${escaped}\\b`, 'i'); | |
| if (regex.test(textToScan)) { | |
| techKeywordMatches++; | |
| } | |
| } | |
| // 4. Calculate composite score (0 to 100) | |
| // Chapter score: up to 25 pts (5 pts per chapter up to 5) | |
| const chapterScore = Math.min(25, detectedChapters.length * 5); | |
| // Outline score: up to 25 pts (5 pts per outline item up to 5) | |
| const outlineScore = Math.min(25, detectedOutlines.length * 5); | |
| // Technical term density: up to 30 pts (6 pts per keyword match up to 5) | |
| const techScore = Math.min(30, techKeywordMatches * 6); | |
| // Title & length structure: up to 20 pts | |
| let structureScore = 0; | |
| if (/tutorial|course|mastery|guide|crash course|bootcamp|full course|for beginners|complete/i.test(metadata.title)) { | |
| structureScore += 10; | |
| } | |
| if (metadata.durationSeconds >= 1800) { // >= 30 mins | |
| structureScore += 10; | |
| } else if (metadata.durationSeconds >= 900) { // >= 15 mins | |
| structureScore += 5; | |
| } | |
| const evidenceScore = chapterScore + outlineScore + techScore + structureScore; | |
| const THRESHOLD = 35; // Minimum required composite score | |
| const hasContentEvidence = evidenceScore >= THRESHOLD; | |
| let rejectionReason: string | undefined; | |
| if (!hasContentEvidence) { | |
| rejectionReason = "GetCert couldn't obtain enough instructional content from this video to verify that it supports a reliable competency assessment. Tip: Courses with structured topic outlines, technical concepts, or chapter breakdowns provide the necessary evidence for evaluation."; | |
| } | |
| return { | |
| hasContentEvidence, | |
| evidenceScore, | |
| chapterCount: detectedChapters.length, | |
| technicalTermDensity: techKeywordMatches, | |
| rejectionReason, | |
| detectedChapters, | |
| detectedOutlines, | |
| }; | |
| } | |
| // βββ 5. Analyze Educational Suitability (Kev / CAV Gatekeeper) ββββββββββββββββββ | |
| export async function analyzeEducationalSuitability( | |
| metadata: YouTubeVideoMetadata, | |
| evidence: EvidenceEvaluationResult | |
| ): Promise<KevDecisionSignals> { | |
| const durationCheck = validateDuration(metadata.durationSeconds); | |
| if (!durationCheck.valid) { | |
| return { | |
| validVideo: true, | |
| durationValid: false, | |
| hasContentEvidence: evidence.hasContentEvidence, | |
| evidenceScore: evidence.evidenceScore, | |
| educational: false, | |
| skillBased: false, | |
| sufficientDepth: false, | |
| assessable: false, | |
| confidence: 0.1, | |
| decision: 'reject', | |
| rejectionReason: durationCheck.reason, | |
| }; | |
| } | |
| if (!evidence.hasContentEvidence) { | |
| return { | |
| validVideo: true, | |
| durationValid: true, | |
| hasContentEvidence: false, | |
| evidenceScore: evidence.evidenceScore, | |
| educational: false, | |
| skillBased: false, | |
| sufficientDepth: false, | |
| assessable: false, | |
| confidence: 0.2, | |
| decision: 'reject', | |
| rejectionReason: evidence.rejectionReason, | |
| }; | |
| } | |
| // Check for non-educational entertainment indicators | |
| const entertainmentRegex = /\b(gameplay|walkthrough stream|music video|official audio|vlog|prank|reaction to|asmr|movie clips|meme)\b/i; | |
| if (entertainmentRegex.test(metadata.title)) { | |
| return { | |
| validVideo: true, | |
| durationValid: true, | |
| hasContentEvidence: evidence.hasContentEvidence, | |
| evidenceScore: evidence.evidenceScore, | |
| educational: false, | |
| skillBased: false, | |
| sufficientDepth: false, | |
| assessable: false, | |
| confidence: 0.15, | |
| decision: 'reject', | |
| rejectionReason: 'The video appears to be entertainment or gameplay media rather than an instructional technical tutorial.', | |
| }; | |
| } | |
| // Run Kev System 1 sidecar if available | |
| let kevScore = 85; | |
| try { | |
| const kevAvailable = await isKevAvailable(); | |
| if (kevAvailable) { | |
| const curriculumChapters = evidence.detectedChapters.map((c) => ({ | |
| timestamp: c.time, | |
| title: c.title, | |
| })); | |
| const kevEval = await evaluateYouTubeCurriculumWithKev( | |
| { | |
| title: metadata.title, | |
| channelTitle: metadata.channelTitle, | |
| durationText: metadata.durationText, | |
| durationSeconds: metadata.durationSeconds, | |
| description: metadata.description, | |
| }, | |
| curriculumChapters, | |
| metadata.transcript | |
| ); | |
| if (kevEval) { | |
| kevScore = kevEval.kevScore; | |
| if (!kevEval.isEducational || !kevEval.hasCurriculum) { | |
| return { | |
| validVideo: true, | |
| durationValid: true, | |
| hasContentEvidence: evidence.hasContentEvidence, | |
| evidenceScore: evidence.evidenceScore, | |
| educational: kevEval.isEducational, | |
| skillBased: kevEval.hasCurriculum, | |
| sufficientDepth: false, | |
| assessable: false, | |
| confidence: Math.max(kevEval.educationalProbability, kevEval.curriculumProbability), | |
| decision: 'reject', | |
| rejectionReason: 'The video curriculum could not be verified by Kev as an instructional technical course with demonstrable learning outcomes.', | |
| }; | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| logger.warn('[YouTube Service] Kev evaluation error, falling back to heuristic scoring:', err); | |
| } | |
| const confidence = Math.min(0.98, Math.max(0.70, (evidence.evidenceScore / 100) * 0.5 + (kevScore / 150) * 0.5)); | |
| return { | |
| validVideo: true, | |
| durationValid: true, | |
| hasContentEvidence: true, | |
| evidenceScore: evidence.evidenceScore, | |
| educational: true, | |
| skillBased: true, | |
| sufficientDepth: true, | |
| assessable: true, | |
| confidence: Number(confidence.toFixed(2)), | |
| decision: 'accept', | |
| }; | |
| } | |
| // βββ 6. Extract Learning Outcomes ββββββββββββββββββββββββββββββββββββββββββββββ | |
| export async function extractLearningOutcomes( | |
| metadata: YouTubeVideoMetadata, | |
| evidence: EvidenceEvaluationResult | |
| ): Promise<LearningOutcomes> { | |
| const combinedText = `${metadata.title}\n${evidence.detectedChapters.map(c => c.title).join('\n')}\n${metadata.description.slice(0, 1000)}${metadata.transcript ? `\n${metadata.transcript.slice(0, 1000)}` : ''}`; | |
| let domain = 'Software Engineering'; | |
| if (/react native|mobile|expo|flutter|ios|android/i.test(combinedText)) { | |
| domain = 'Mobile Application Engineering'; | |
| } else if (/react|frontend|css|html|vue|angular|tailwind/i.test(combinedText)) { | |
| domain = 'Frontend Web Development'; | |
| } else if (/python|django|fastapi|flask|data science|machine learning/i.test(combinedText)) { | |
| domain = 'Python Programming & Data Engineering'; | |
| } else if (/rust|cargo|borrowing|ownership|mutability/i.test(combinedText)) { | |
| domain = 'Rust Systems Programming'; | |
| } else if (/c\+\+|cpp|c#|golang|\bgo\b|java/i.test(combinedText)) { | |
| domain = 'Systems & Core Software Engineering'; | |
| } else if (/node|express|backend|sql|postgres|mongodb|database/i.test(combinedText)) { | |
| domain = 'Backend & Systems Architecture'; | |
| } else if (/docker|kubernetes|aws|devops|ci\/cd|cloud/i.test(combinedText)) { | |
| domain = 'Cloud & DevOps Infrastructure'; | |
| } | |
| // Extract skill tags from title and tags using strict word boundaries | |
| const matchedTags: string[] = []; | |
| const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| for (const kw of TECHNICAL_KEYWORDS) { | |
| const escaped = escapeRegExp(kw); | |
| const regex = new RegExp(`\\b${escaped}\\b`, 'i'); | |
| if (regex.test(combinedText)) { | |
| const formatted = kw | |
| .split(' ') | |
| .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) | |
| .join(' '); | |
| matchedTags.push(formatted); | |
| } | |
| } | |
| const skillTags = Array.from(new Set(matchedTags)).slice(0, 5); | |
| if (skillTags.length === 0) { | |
| skillTags.push('Computer Science', 'Application Development'); | |
| } | |
| // Determine Level | |
| let level: LearningOutcomes['level'] = 'Intermediate'; | |
| if (/beginner|for beginners|basics|starter|crash course|fundamentals/i.test(metadata.title)) { | |
| level = 'Beginner'; | |
| } else if (/advanced|deep dive|internals|architecture|masterclass/i.test(metadata.title)) { | |
| level = 'Advanced'; | |
| } else if (metadata.durationSeconds >= 7200) { // >= 2 hours | |
| level = 'Comprehensive'; | |
| } | |
| // Determine outcomes from chapters or fallback | |
| const outcomes: string[] = []; | |
| if (evidence.detectedChapters.length >= 3) { | |
| for (const ch of evidence.detectedChapters.slice(0, 6)) { | |
| outcomes.push(ch.title.replace(/^[\d.:\s-]+/, '')); | |
| } | |
| } else { | |
| outcomes.push( | |
| 'Core syntax, execution environment, and configuration', | |
| 'Fundamental data structures, types, and operations', | |
| 'Modular architecture, functions, and abstractions', | |
| 'Real-world implementation, debugging, and best practices' | |
| ); | |
| } | |
| return { | |
| domain, | |
| skillTags, | |
| level, | |
| outcomes: outcomes.slice(0, 5), | |
| }; | |
| } | |
| // βββ 7. Synthesize Certification Title & Syllabus ββββββββββββββββββββββββββββββ | |
| export function synthesizeCertification( | |
| metadata: YouTubeVideoMetadata, | |
| evidence: EvidenceEvaluationResult, | |
| outcomes: LearningOutcomes | |
| ): SynthesizedCertification { | |
| // Clean raw YouTube title to a professional, resume-grade course title | |
| let cleanTitle = metadata.title | |
| .replace(/\[.*?\]|\(.*?\)/g, '') // remove brackets | |
| .split('|')[0] // extract primary topic before pipe if present | |
| .replace(/(?:full\s+course|tutorial|crash\s+course|in\s+\d+\s+hours?|for\s+beginners|\b20\d\d\b|new|free|complete)/gi, '') | |
| .replace(/[!|#]+/g, '') | |
| .trim(); | |
| // Normalize multiple spaces | |
| cleanTitle = cleanTitle.replace(/\s{2,}/g, ' ').trim(); | |
| if (cleanTitle.length < 5 || cleanTitle.split(' ').length < 2) { | |
| cleanTitle = `${outcomes.skillTags[0] || 'Technical'} Application`; | |
| } | |
| cleanTitle = `${cleanTitle} Competency & Application`; | |
| const syllabus: CurriculumModuleItem[] = [ | |
| { | |
| id: 'mod_1', | |
| title: 'Foundations & Architecture', | |
| moduleTitle: 'Foundations & Architecture', | |
| topics: outcomes.outcomes.slice(0, 2), | |
| subtopics: outcomes.outcomes.slice(0, 2), | |
| }, | |
| { | |
| id: 'mod_2', | |
| title: 'Core Implementation & Logic', | |
| moduleTitle: 'Core Implementation & Logic', | |
| topics: outcomes.outcomes.slice(2, 4).length > 0 ? outcomes.outcomes.slice(2, 4) : ['Core logic patterns', 'Data handling and transformations'], | |
| subtopics: outcomes.outcomes.slice(2, 4).length > 0 ? outcomes.outcomes.slice(2, 4) : ['Core logic patterns', 'Data handling and transformations'], | |
| }, | |
| { | |
| id: 'mod_3', | |
| title: 'Production Practice & Verification', | |
| moduleTitle: 'Production Practice & Verification', | |
| topics: ['Error handling & edge cases', 'Application structure & engineering best practices'], | |
| subtopics: ['Error handling & edge cases', 'Application structure & engineering best practices'], | |
| }, | |
| ]; | |
| return { | |
| courseTitle: cleanTitle, | |
| sourceVideoTitle: metadata.title, | |
| sourceChannelTitle: metadata.channelTitle, | |
| domain: outcomes.domain, | |
| level: outcomes.level, | |
| skillTags: outcomes.skillTags, | |
| learningOutcomes: outcomes.outcomes, | |
| syllabus, | |
| }; | |
| } | |
| // βββ 8. Generate 12 Objectively Gradeable MCQs ββββββββββββββββββββββββββββββββββ | |
| // (8 Applied MCQs + 2 Scenario MCQs + 2 Conceptual MCQs) | |
| export function generateAssessmentQuestions( | |
| certification: SynthesizedCertification | |
| ): { clientPayload: ClientAssessmentPayload; serverSnapshot: ServerAssessmentSnapshot } { | |
| const primarySkill = certification.skillTags[0] || 'Software Engineering'; | |
| const secondarySkill = certification.skillTags[1] || 'System Architecture'; | |
| const versionId = `yt_v1_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; | |
| const snapshotId = `snap_${versionId}`; | |
| // 12 High-Yield, applied questions tailored to the skill & domain | |
| const rawQuestions: Array<{ | |
| type: QuestionType; | |
| category: string; | |
| question: string; | |
| options: string[]; | |
| answerIndex: number; | |
| explanation: string; | |
| }> = [ | |
| // ββ 8 Applied MCQs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| type: 'applied_mcq', | |
| category: 'Data Structures & Types', | |
| question: `In ${primarySkill}, you need to store an ordered collection of values where items may be dynamically appended, removed, or updated during program execution. Which approach is most appropriate?`, | |
| options: [ | |
| 'An immutable tuple or fixed buffer', | |
| 'A mutable dynamic list, array, or slice', | |
| 'An unordered key-value hash table', | |
| 'A static constant primitive', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Mutable lists or dynamic arrays support indexed access and variable-length modifications during runtime.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Control Flow & Logic', | |
| question: `When validating multiple mutually exclusive conditions where only one specific branch should execute based on an incoming value, what construct provides the cleanest predictable flow?`, | |
| options: [ | |
| 'Consecutive independent single if statements', | |
| 'A chained if-else / switch pattern evaluating each condition sequentially', | |
| 'An unhandled try-catch block wrapping all cases', | |
| 'A busy-waiting while loop', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Chained if-else or pattern-matching switch constructs guarantee that only the first matching branch executes.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Function Design & Scope', | |
| question: `A utility function needs to compute a transformed dataset without modifying or mutating the original input array passed from an outer scope. What paradigm ensures safety?`, | |
| options: [ | |
| 'Directly reassigning items within the input reference', | |
| 'Pure functional transformation returning a newly allocated copy', | |
| 'Storing intermediate calculations in global variables', | |
| 'Mutating caller scope arguments in place', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Pure functions avoid side-effects by treating inputs as immutable and returning fresh copies of transformed data.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Error & Exception Handling', | |
| question: `When executing an I/O operation (such as reading a file or making a network request) that may fail due to external conditions, how should potential errors be managed?`, | |
| options: [ | |
| 'Ignoring errors and allowing the runtime thread to terminate uncaught', | |
| 'Wrapping the call in structured exception handling (try-catch/try-except) with specific recovery', | |
| 'Hardcoding a 5-second sleep timer before the call', | |
| 'Suppressing all errors by wrapping in an empty catch block without logging', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Structured exception handling intercepts foreseeable runtime exceptions and permits graceful degradation or error reporting.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Asynchronous Execution', | |
| question: `In asynchronous operations, what is the primary benefit of using async/await syntax over deeply nested callback functions?`, | |
| options: [ | |
| 'It converts single-threaded code into multi-threaded assembly execution', | |
| 'It allows non-blocking asynchronous code to be read and reasoned about sequentially', | |
| 'It eliminates network latency completely', | |
| 'It bypasses the JavaScript or runtime event loop', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Async/await provides non-blocking, synchronous-looking readability while maintaining non-blocking event-loop execution.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Resource Management', | |
| question: `After opening a database connection or streaming a file descriptor, what is the standard practice to prevent memory leaks and unreleased file locks?`, | |
| options: [ | |
| 'Relying solely on operating system process termination', | |
| 'Explicitly closing resources in a finally block or utilizing auto-closing context managers', | |
| 'Reopening the resource immediately to flush buffers', | |
| 'Setting the file pointer to null without calling close()', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Using finally blocks or context managers (e.g. using/with) guarantees that cleanup logic runs even if an unhandled exception occurs.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'State & Mutation', | |
| question: `When multiple components or modules read from a shared state, why is direct in-place mutation of nested objects considered an anti-pattern?`, | |
| options: [ | |
| 'It causes memory to immediately overflow the stack', | |
| 'It breaks change detection and leads to unpredictable race conditions or out-of-sync UI', | |
| 'It prevents the application from compiling into bytecode', | |
| 'It requires administrative operating system permissions', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Direct mutations obscure when and where data changes occurred, disrupting reactive change-detection loops and state predictability.', | |
| }, | |
| { | |
| type: 'applied_mcq', | |
| category: 'Modular Code Organization', | |
| question: `Why is it considered best practice to separate business logic from UI rendering or transport controllers?`, | |
| options: [ | |
| 'It allows the same core domain logic to be unit tested and reused across different views or endpoints', | |
| 'It doubles the execution speed of the V8 or runtime interpreter', | |
| 'It is required by the YouTube API specification', | |
| 'It eliminates the need for unit testing', | |
| ], | |
| answerIndex: 0, | |
| explanation: 'Decoupling domain logic from presentation or transport layers enhances maintainability, testability, and portability.', | |
| }, | |
| // ββ 2 Scenario-Based MCQs βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| { | |
| type: 'scenario_mcq', | |
| category: 'Architecture Scenario', | |
| question: `Scenario: You are designing an API service that receives periodic spikes of 10,000 analytics events per minute. Processing each event takes 250ms of heavy database writes. Direct synchronous processing causes HTTP timeouts. What architectural pattern resolves this?`, | |
| options: [ | |
| 'Increase the HTTP client timeout to 10 minutes and process sequentially', | |
| 'Enqueue incoming events into an asynchronous message queue (e.g. RabbitMQ/Redis) with background worker consumers', | |
| 'Drop 50% of incoming events randomly to keep response latency low', | |
| 'Execute all writes inside a synchronous blocking while-loop on the main web thread', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Asynchronous queues decouple ingestion from processing, absorbing traffic spikes and executing background jobs without timing out HTTP requests.', | |
| }, | |
| { | |
| type: 'scenario_mcq', | |
| category: 'Debugging & Reliability Scenario', | |
| question: `Scenario: A microservice reports intermittent memory growth (OOM crashes) after running for 48 hours in production. Logs reveal that event listeners are being registered on a global event bus on every incoming HTTP request. What is the root cause?`, | |
| options: [ | |
| 'The database schema is missing a primary key index', | |
| 'Event listeners are accumulating indefinitely on the global emitter without being unsubscribed, causing a reference leak', | |
| 'The server CPU clock is drifting out of synchronization', | |
| 'The network router is buffering TCP keep-alive packets', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'Registering handlers on long-lived global objects on every ephemeral request retains handler scopes in memory, resulting in a classic memory leak.', | |
| }, | |
| // ββ 2 Conceptual / Deep Application MCQs ββββββββββββββββββββββββββββββββββββ | |
| { | |
| type: 'conceptual_mcq', | |
| category: 'Algorithmic Complexity & Scale', | |
| question: `Conceptual: An algorithm searches for an item in a list of 1,000,000 items. Algorithm A performs a linear scan (O(N)), while Algorithm B performs a binary search on a sorted index (O(log N)). In the worst case, approximately how many comparisons will Algorithm B make?`, | |
| options: [ | |
| 'Approximately 1,000,000 comparisons', | |
| 'Approximately 500,000 comparisons', | |
| 'Approximately 20 comparisons', | |
| 'Exactly 1 comparison', | |
| ], | |
| answerIndex: 2, | |
| explanation: 'Log2(1,000,000) is approximately 19.93, meaning a binary search finds any element in at most 20 comparisons.', | |
| }, | |
| { | |
| type: 'conceptual_mcq', | |
| category: 'Idempotency & Distributed Systems', | |
| question: `Conceptual: In payment gateways and network services, what does it mean for an endpoint to be "idempotent"?`, | |
| options: [ | |
| 'The endpoint processes requests faster on consecutive retries', | |
| 'Making the same request multiple times produces the identical side-effect and outcome as making it once', | |
| 'The endpoint can only be called from a single authenticated IP address', | |
| 'The endpoint encrypts all payloads using public-key cryptography', | |
| ], | |
| answerIndex: 1, | |
| explanation: 'An idempotent operation can be repeated safely without creating duplicate records or unintended side-effects (e.g. charging a card twice).', | |
| }, | |
| ]; | |
| // Convert to private questions (with answer keys) and public questions (without keys) | |
| const privateQuestions: AssessmentQuestionPrivate[] = rawQuestions.map((q, idx) => ({ | |
| id: `q_${idx + 1}`, | |
| index: idx + 1, | |
| type: q.type, | |
| category: q.category, | |
| question: q.question, | |
| options: q.options, | |
| answerIndex: q.answerIndex, | |
| explanation: q.explanation, | |
| })); | |
| const publicQuestions: AssessmentQuestionPublic[] = privateQuestions.map((q) => ({ | |
| id: q.id, | |
| index: q.index, | |
| type: q.type, | |
| category: q.category, | |
| question: q.question, | |
| prompt: q.question, | |
| options: q.options, | |
| })); | |
| const clientPayload: ClientAssessmentPayload = { | |
| versionId, | |
| totalQuestions: 12, | |
| timeLimitMinutes: 25, | |
| passingScorePercentage: 70, | |
| certificationFeeInr: 59, | |
| questions: publicQuestions, | |
| }; | |
| const serverSnapshot: ServerAssessmentSnapshot = { | |
| snapshotId, | |
| versionId, | |
| createdAt: new Date().toISOString(), | |
| source: { | |
| videoId: certification.sourceVideoTitle, | |
| sourceUrl: `https://www.youtube.com/watch?v=${certification.sourceVideoTitle}`, | |
| originalTitle: certification.sourceVideoTitle, | |
| channelName: certification.sourceChannelTitle, | |
| durationSeconds: 1800, | |
| durationText: '30m', | |
| }, | |
| certification, | |
| assessment: { | |
| version: '1.0', | |
| timeLimitMinutes: 25, | |
| passingScorePercentage: 70, | |
| scoringRules: { | |
| totalQuestions: 12, | |
| passingThreshold: 70, | |
| }, | |
| questions: privateQuestions, | |
| }, | |
| }; | |
| // Store snapshot in memory cache | |
| snapshotCache.set(snapshotId, serverSnapshot); | |
| snapshotCache.set(versionId, serverSnapshot); | |
| return { clientPayload, serverSnapshot }; | |
| } | |
| // βββ 9. Assessment Session & Timer Management ββββββββββββββββββββββββββββββββββ | |
| export function startAssessmentAttempt( | |
| snapshotId: string, | |
| userId?: string | |
| ): { | |
| attemptId: string; | |
| startedAt: string; | |
| attemptStartedAt: string; | |
| expiresAt: string; | |
| timeLimitMinutes: number; | |
| timeLimitSeconds: number; | |
| } { | |
| const attemptId = `att_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; | |
| const nowMs = Date.now(); | |
| const timeLimitMinutes = 25; | |
| const expiresAtMs = nowMs + timeLimitMinutes * 60 * 1000; | |
| activeAttempts.set(attemptId, { | |
| startedAtMs: nowMs, | |
| snapshotId, | |
| userId, | |
| }); | |
| return { | |
| attemptId, | |
| startedAt: new Date(nowMs).toISOString(), | |
| attemptStartedAt: new Date(nowMs).toISOString(), | |
| expiresAt: new Date(expiresAtMs).toISOString(), | |
| timeLimitMinutes, | |
| timeLimitSeconds: timeLimitMinutes * 60, | |
| }; | |
| } | |
| // βββ 10. Grade Assessment Submission (100% Deterministic) βββββββββββββββββββββββ | |
| export interface QuestionExplanationItem { | |
| id: string; | |
| questionId: string; | |
| index: number; | |
| question: string; | |
| prompt: string; | |
| type?: string; | |
| userAnswerIndex: number; | |
| selectedIndex: number; | |
| correctAnswerIndex: number; | |
| correctIndex: number; | |
| isCorrect: boolean; | |
| explanation: string; | |
| } | |
| export interface GradingResult { | |
| attemptId: string; | |
| snapshotId: string; | |
| passed: boolean; | |
| score: number; | |
| scorePercentage: number; | |
| correctCount: number; | |
| totalQuestions: number; | |
| passThreshold: number; | |
| attemptDurationSeconds: number; | |
| summary: string; | |
| questionResults: QuestionExplanationItem[]; | |
| explanations: QuestionExplanationItem[]; | |
| } | |
| export function gradeAssessmentSubmission( | |
| snapshotId: string, | |
| attemptId: string, | |
| submittedAnswers: Record<string, number> | |
| ): GradingResult { | |
| const snapshot = snapshotCache.get(snapshotId); | |
| if (!snapshot) { | |
| throw new Error('Assessment session or snapshot not found. Please restart the assessment.'); | |
| } | |
| const attempt = activeAttempts.get(attemptId); | |
| const nowMs = Date.now(); | |
| let attemptDurationSeconds = 0; | |
| if (attempt) { | |
| attemptDurationSeconds = Math.round((nowMs - attempt.startedAtMs) / 1000); | |
| // Server-authoritative timer enforcement: 25 minutes + 30 seconds network grace | |
| const MAX_ALLOWED_MS = (25 * 60 + 30) * 1000; | |
| if (nowMs - attempt.startedAtMs > MAX_ALLOWED_MS) { | |
| throw new Error('Assessment attempt expired. The 25-minute examination window has elapsed.'); | |
| } | |
| } | |
| const questions = snapshot.assessment.questions; | |
| let correctCount = 0; | |
| const questionResults: QuestionExplanationItem[] = questions.map((q) => { | |
| const userAnswerIndex = typeof submittedAnswers[q.id] === 'number' ? submittedAnswers[q.id] : -1; | |
| const isCorrect = userAnswerIndex === q.answerIndex; | |
| if (isCorrect) correctCount++; | |
| return { | |
| id: q.id, | |
| questionId: q.id, | |
| index: q.index, | |
| question: q.question, | |
| prompt: q.question, | |
| type: q.type, | |
| userAnswerIndex, | |
| selectedIndex: userAnswerIndex, | |
| correctAnswerIndex: q.answerIndex, | |
| correctIndex: q.answerIndex, | |
| isCorrect, | |
| explanation: q.explanation, | |
| }; | |
| }); | |
| const totalQuestions = questions.length; | |
| const scorePercentage = Math.round((correctCount / totalQuestions) * 100); | |
| const passingScore = snapshot.assessment.passingScorePercentage || 70; | |
| const passed = scorePercentage >= passingScore; | |
| // Cleanup active attempt after submission | |
| activeAttempts.delete(attemptId); | |
| return { | |
| attemptId, | |
| snapshotId, | |
| passed, | |
| score: scorePercentage, | |
| scorePercentage, | |
| correctCount, | |
| totalQuestions, | |
| passThreshold: passingScore, | |
| attemptDurationSeconds, | |
| summary: passed | |
| ? `Congratulations! You scored ${scorePercentage}% (${correctCount}/${totalQuestions}), exceeding the ${passingScore}% passing threshold.` | |
| : `You scored ${scorePercentage}% (${correctCount}/${totalQuestions}), which is below the ${passingScore}% passing threshold. You can review the explanations and try again.`, | |
| questionResults, | |
| explanations: questionResults, | |
| }; | |
| } | |
| export function getAssessmentSnapshot(snapshotId: string): ServerAssessmentSnapshot | undefined { | |
| return snapshotCache.get(snapshotId); | |
| } | |