Spaces:
Running
Running
File size: 45,271 Bytes
515e3fb 82855ca 515e3fb 82855ca 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb cb6ced0 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb cb6ced0 515e3fb cb6ced0 0ca32d5 cb6ced0 a1fe4fb cb6ced0 0ca32d5 a1fe4fb cb6ced0 a1fe4fb 0ca32d5 a1fe4fb 82855ca a1fe4fb 82855ca a1fe4fb cb6ced0 a1fe4fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb 0ca32d5 cb6ced0 0ca32d5 cb6ced0 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 82855ca 515e3fb 82855ca 515e3fb 82855ca 515e3fb 82855ca 515e3fb 82855ca 515e3fb 0ca32d5 515e3fb cb6ced0 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb 0ca32d5 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb a1fe4fb 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb cb6ced0 515e3fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 | 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);
}
|