@playcademy/sandbox 0.12.2-beta.3 → 0.12.2-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +404 -92
  2. package/dist/server.js +404 -92
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2517,7 +2517,7 @@ var package_default;
2517
2517
  var init_package = __esm(() => {
2518
2518
  package_default = {
2519
2519
  name: "@playcademy/sandbox",
2520
- version: "0.12.2-beta.3",
2520
+ version: "0.12.2-beta.4",
2521
2521
  description: "Local development server for Playcademy game development",
2522
2522
  type: "module",
2523
2523
  exports: {
@@ -81865,6 +81865,9 @@ function normalizedIdentityCase2(value) {
81865
81865
  function frameworkAliasKey2(value) {
81866
81866
  return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
81867
81867
  }
81868
+ function isUuidShaped2(value) {
81869
+ return UUID_PATTERN2.test(value.trim());
81870
+ }
81868
81871
  function isCommonCoreMathIdentifier2(identifier) {
81869
81872
  const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
81870
81873
  return canonical.startsWith("CCSS.MATH.") || /^(?:K|[1-8])\.(?:CC|OA|NBT|NF|MD|RP|NS|EE|F|G|SP)\./.test(canonical) || /^(?:HS)?(?:N|A|F|G|S)-[A-Z]+\./.test(canonical) || /^MP\.?\d/.test(canonical);
@@ -81896,6 +81899,14 @@ function canonicalIdentifier2(framework, authoredIdentifier) {
81896
81899
  }
81897
81900
  return identifier;
81898
81901
  }
81902
+ function readableAlignmentIdentifier2(alignment) {
81903
+ const identifier = alignment.identifier?.trim() ?? "";
81904
+ if (identifier && !isUuidShaped2(identifier)) {
81905
+ return identifier;
81906
+ }
81907
+ const id = alignment.id.trim();
81908
+ return id && !isUuidShaped2(id) ? id : "";
81909
+ }
81899
81910
  function canonicalAssessmentStandardRef2(standard) {
81900
81911
  const authoredFramework = normalizedWhitespace2(standard.framework);
81901
81912
  const framework = canonicalFramework2(authoredFramework, standard.identifier);
@@ -82002,7 +82013,23 @@ function qtiStandardsFromMetadata(metadata2) {
82002
82013
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
82003
82014
  return dedupeStandards2(standards);
82004
82015
  }
82005
- var EVENT_HANDLER_ATTRIBUTE, SCRIPT_SCHEMES2, DATA_RASTER_IMAGE_PATTERN2, QTI_MATHML_ALLOWED_TAGS2, metadataSymbol2, parser2, SUPPORTED_QTI_INTERACTION_TYPES2, URL_ATTRIBUTES3, IMAGE_URL_ATTRIBUTES3, INTERACTION_TYPES2, XML_NAMED_ENTITIES, EMPHASIS_TAGS2, STRONG_TAGS2, CONTENT_SKIPPED_TAGS2, BLOCK_CONTENT_KINDS2, GAP_MATCH_TOKEN_TAGS2, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2, BLANK_SENTINEL2 = "￿", MARKED_BLANK_PATTERN2, MARKED_BLANK_GLOBAL_PATTERN2, REGION_SHAPE_COORDS, QTI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0", QTI_AUTHORING_BLANK_MARKER = "___", MATCH_CORRECT_TEMPLATE2 = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE2 = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES2, RESPONSE_PROCESSING_TEMPLATES2, GLOBAL_BODY_ATTRIBUTES, BODY_ELEMENT_ATTRIBUTES, MATHML_ATTRIBUTES, VOID_BODY_ELEMENTS2, URL_ATTRIBUTES22, IMAGE_URL_ATTRIBUTES22, INTERACTION_ATTRIBUTES, CHOICE_ATTRIBUTES, CHOICE_ELEMENT_ATTRIBUTES, VOID_CHOICE_ELEMENTS2, NUMERIC_COMPARISON_ATTRIBUTES2, RESPONSE_CARDINALITIES2, RESPONSE_BASE_TYPES2, BLANK_SENTINEL22 = "￿", QTI_FLOAT_VALUE, PLAYABLE_POINT_VALUE, POINT_INTERACTION_TYPES2, INDEPENDENT_PROCESSING_TAGS2, PRIVATE_PLAYABLE_KEYS2, trimmedNonEmptyString2, finiteNumber22, positiveFiniteNumber2, IDENTIFIER_VALUE2, DIRECTED_PAIR_VALUE2, scoringMatchComparisonSchema2, scoringNumericEqualComparisonSchema2, scoringNumericEqualRoundedComparisonSchema2, scoringRuleSchema2, scoringRulesSchema2, scoringItemSchema2, assessmentScoringArtifactItemsSchema2, assessmentScoringArtifactSchema2, SUPPORTED_BASE_TYPES2, SUPPORTED_CARDINALITIES2, MATCH_CORRECT_TEMPLATES2, QtiScoringArtifactValidationError, COMMON_CORE_MATH_FRAMEWORK_ALIASES2, COMMON_CORE_ELA_FRAMEWORK_ALIASES2, COMMON_CORE_FRAMEWORK_ALIASES2;
82016
+ function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
82017
+ const refs = new Map;
82018
+ for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
82019
+ const identifier = readableAlignmentIdentifier2(alignment);
82020
+ if (identifier) {
82021
+ const canonical = canonicalAssessmentStandardRef2({
82022
+ framework: alignment.source,
82023
+ identifier
82024
+ });
82025
+ if (canonical.framework && canonical.identifier) {
82026
+ refs.set(assessmentStandardRefKey2(canonical), canonical);
82027
+ }
82028
+ }
82029
+ }
82030
+ return [...refs.values()];
82031
+ }
82032
+ var EVENT_HANDLER_ATTRIBUTE, SCRIPT_SCHEMES2, DATA_RASTER_IMAGE_PATTERN2, QTI_MATHML_ALLOWED_TAGS2, metadataSymbol2, parser2, SUPPORTED_QTI_INTERACTION_TYPES2, URL_ATTRIBUTES3, IMAGE_URL_ATTRIBUTES3, INTERACTION_TYPES2, XML_NAMED_ENTITIES, EMPHASIS_TAGS2, STRONG_TAGS2, CONTENT_SKIPPED_TAGS2, BLOCK_CONTENT_KINDS2, GAP_MATCH_TOKEN_TAGS2, STAGE_GRAPHIC_EXCLUDED_CONTAINERS2, BLANK_SENTINEL2 = "￿", MARKED_BLANK_PATTERN2, MARKED_BLANK_GLOBAL_PATTERN2, REGION_SHAPE_COORDS, QTI_NAMESPACE = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0", QTI_AUTHORING_BLANK_MARKER = "___", MATCH_CORRECT_TEMPLATE2 = "http://www.imsglobal.org/question/qti_v3p0/rptemplates/match_correct", MAP_RESPONSE_TEMPLATE2 = "https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/map_response", MAP_RESPONSE_TEMPLATES2, RESPONSE_PROCESSING_TEMPLATES2, GLOBAL_BODY_ATTRIBUTES, BODY_ELEMENT_ATTRIBUTES, MATHML_ATTRIBUTES, VOID_BODY_ELEMENTS2, URL_ATTRIBUTES22, IMAGE_URL_ATTRIBUTES22, INTERACTION_ATTRIBUTES, CHOICE_ATTRIBUTES, CHOICE_ELEMENT_ATTRIBUTES, VOID_CHOICE_ELEMENTS2, NUMERIC_COMPARISON_ATTRIBUTES2, RESPONSE_CARDINALITIES2, RESPONSE_BASE_TYPES2, BLANK_SENTINEL22 = "￿", QTI_FLOAT_VALUE, PLAYABLE_POINT_VALUE, POINT_INTERACTION_TYPES2, INDEPENDENT_PROCESSING_TAGS2, PRIVATE_PLAYABLE_KEYS2, trimmedNonEmptyString2, finiteNumber22, positiveFiniteNumber2, IDENTIFIER_VALUE2, DIRECTED_PAIR_VALUE2, scoringMatchComparisonSchema2, scoringNumericEqualComparisonSchema2, scoringNumericEqualRoundedComparisonSchema2, scoringRuleSchema2, scoringRulesSchema2, scoringItemSchema2, assessmentScoringArtifactItemsSchema2, assessmentScoringArtifactSchema2, SUPPORTED_BASE_TYPES2, SUPPORTED_CARDINALITIES2, MATCH_CORRECT_TEMPLATES2, QtiScoringArtifactValidationError, UUID_PATTERN2, COMMON_CORE_MATH_FRAMEWORK_ALIASES2, COMMON_CORE_ELA_FRAMEWORK_ALIASES2, COMMON_CORE_FRAMEWORK_ALIASES2;
82006
82033
  var init_qti = __esm(() => {
82007
82034
  init_timeback4();
82008
82035
  init_timeback4();
@@ -82364,6 +82391,7 @@ var init_qti = __esm(() => {
82364
82391
  this.name = "QtiScoringArtifactValidationError";
82365
82392
  }
82366
82393
  };
82394
+ UUID_PATTERN2 = /^(?:urn:uuid:)?\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i;
82367
82395
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
82368
82396
  "ccssm",
82369
82397
  "ccssmath",
@@ -82573,7 +82601,7 @@ function pruneUnreadableStandards(standards) {
82573
82601
  return standards.filter(hasReadableStandardLabel);
82574
82602
  }
82575
82603
  function parseQuestionIndex(title) {
82576
- const match = title.match(/\bquestion[\s_-]*(\d+)\b/i);
82604
+ const match = title?.match(/\bquestion[\s_-]*(\d+)\b/i);
82577
82605
  const index = match ? Number(match[1]) : Number.NaN;
82578
82606
  return Number.isFinite(index) && index > 0 ? index : null;
82579
82607
  }
@@ -82607,14 +82635,15 @@ function qtiQuestionsByIndex(qtiQuestions) {
82607
82635
  }
82608
82636
  return questionsByIndex;
82609
82637
  }
82638
+ function normalizedQuestionScore(score, scorePercentile) {
82639
+ const rawValue = numberField(score) ?? numberField(scorePercentile);
82640
+ return rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
82641
+ }
82610
82642
  function getQuestionCorrectness(result) {
82611
82643
  if (!result) {
82612
82644
  return "unknown";
82613
82645
  }
82614
- const score = numberField(result.score);
82615
- const scorePercentile = numberField(result.scorePercentile);
82616
- const rawValue = score ?? scorePercentile;
82617
- const value = rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
82646
+ const value = normalizedQuestionScore(result.score, result.scorePercentile);
82618
82647
  if (value === null) {
82619
82648
  return "unknown";
82620
82649
  }
@@ -82878,10 +82907,71 @@ var init_timeback_mastery_completion_util = __esm(async () => {
82878
82907
  await init_errors8();
82879
82908
  });
82880
82909
 
82910
+ // ../api-core/src/utils/bounded-map.util.ts
82911
+ function setBounded(map, key, value, maxSize) {
82912
+ map.delete(key);
82913
+ if (map.size >= maxSize) {
82914
+ const oldestKey = map.keys().next().value;
82915
+ if (oldestKey !== undefined) {
82916
+ map.delete(oldestKey);
82917
+ }
82918
+ }
82919
+ map.set(key, value);
82920
+ }
82921
+
82922
+ // ../api-core/src/utils/timeback-qti-test-lookup.util.ts
82923
+ async function resolveQtiTestId(client, testName, parentLineItem) {
82924
+ const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
82925
+ const cached = cache.get(cacheKey);
82926
+ if (cached) {
82927
+ setBounded(cache, cacheKey, cached, MAX_CACHE_SIZE);
82928
+ return cached;
82929
+ }
82930
+ let resource = null;
82931
+ const resourceId = sourceIdFromRef(parentLineItem.componentResource);
82932
+ if (resourceId) {
82933
+ resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
82934
+ }
82935
+ for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
82936
+ const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
82937
+ if (test?.identifier) {
82938
+ setBounded(cache, cacheKey, test.identifier, MAX_CACHE_SIZE);
82939
+ return test.identifier;
82940
+ }
82941
+ }
82942
+ const normalizedTestName = testName.trim();
82943
+ if (!normalizedTestName) {
82944
+ return "";
82945
+ }
82946
+ for (let page = 1;page <= TITLE_LOOKUP_MAX_PAGES; page++) {
82947
+ const list = await client.qtiApi.assessmentTests.list({
82948
+ query: normalizedTestName,
82949
+ limit: TITLE_LOOKUP_LIMIT,
82950
+ page,
82951
+ sort: "title",
82952
+ order: "asc"
82953
+ }).catch(() => null);
82954
+ const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
82955
+ if (qtiTestId) {
82956
+ setBounded(cache, cacheKey, qtiTestId, MAX_CACHE_SIZE);
82957
+ return qtiTestId;
82958
+ }
82959
+ const itemCount = list?.items?.length ?? 0;
82960
+ if (!list || itemCount < TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
82961
+ break;
82962
+ }
82963
+ }
82964
+ return "";
82965
+ }
82966
+ var MAX_CACHE_SIZE = 500, TITLE_LOOKUP_LIMIT = 50, TITLE_LOOKUP_MAX_PAGES = 5, cache;
82967
+ var init_timeback_qti_test_lookup_util = __esm(() => {
82968
+ init_timeback_grade_level_results_util();
82969
+ cache = new Map;
82970
+ });
82971
+
82881
82972
  // ../api-core/src/services/timeback-admin.service.ts
82882
82973
  class TimebackAdminService {
82883
82974
  deps;
82884
- qtiTestIdCache = new Map;
82885
82975
  static XP_PRECISION_FACTOR = 10;
82886
82976
  static RECENT_ACTIVITY_LIMIT = 20;
82887
82977
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
@@ -82896,9 +82986,6 @@ class TimebackAdminService {
82896
82986
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
82897
82987
  static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
82898
82988
  static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
82899
- static MAX_QTI_TEST_ID_CACHE_SIZE = 500;
82900
- static QTI_TEST_TITLE_LOOKUP_LIMIT = 50;
82901
- static QTI_TEST_TITLE_LOOKUP_MAX_PAGES = 5;
82902
82989
  constructor(deps) {
82903
82990
  this.deps = deps;
82904
82991
  }
@@ -83758,68 +83845,6 @@ class TimebackAdminService {
83758
83845
  return { activities: activitiesWithGameMetrics, hasMore };
83759
83846
  });
83760
83847
  }
83761
- rememberQtiTestId(cacheKey, qtiTestId) {
83762
- if (!qtiTestId) {
83763
- this.qtiTestIdCache.delete(cacheKey);
83764
- return;
83765
- }
83766
- if (this.qtiTestIdCache.has(cacheKey)) {
83767
- this.qtiTestIdCache.delete(cacheKey);
83768
- } else if (this.qtiTestIdCache.size >= TimebackAdminService.MAX_QTI_TEST_ID_CACHE_SIZE) {
83769
- const oldestKey = this.qtiTestIdCache.keys().next().value;
83770
- if (oldestKey !== undefined) {
83771
- this.qtiTestIdCache.delete(oldestKey);
83772
- }
83773
- }
83774
- this.qtiTestIdCache.set(cacheKey, qtiTestId);
83775
- }
83776
- async resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem) {
83777
- const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
83778
- const cachedQtiTestId = cacheKey ? this.qtiTestIdCache.get(cacheKey) : undefined;
83779
- if (cachedQtiTestId) {
83780
- this.rememberQtiTestId(cacheKey, cachedQtiTestId);
83781
- return cachedQtiTestId;
83782
- }
83783
- let resource = null;
83784
- const resourceId = sourceIdFromRef(parentLineItem.componentResource);
83785
- if (resourceId) {
83786
- resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
83787
- }
83788
- for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
83789
- const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
83790
- if (test?.identifier) {
83791
- if (cacheKey) {
83792
- this.rememberQtiTestId(cacheKey, test.identifier);
83793
- }
83794
- return test.identifier;
83795
- }
83796
- }
83797
- const normalizedTestName = testName.trim();
83798
- if (!normalizedTestName) {
83799
- return "";
83800
- }
83801
- for (let page = 1;page <= TimebackAdminService.QTI_TEST_TITLE_LOOKUP_MAX_PAGES; page++) {
83802
- const list = await client.qtiApi.assessmentTests.list({
83803
- query: normalizedTestName,
83804
- limit: TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT,
83805
- page,
83806
- sort: "title",
83807
- order: "asc"
83808
- }).catch(() => null);
83809
- const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
83810
- if (qtiTestId) {
83811
- if (cacheKey) {
83812
- this.rememberQtiTestId(cacheKey, qtiTestId);
83813
- }
83814
- return qtiTestId;
83815
- }
83816
- const itemCount = list?.items?.length ?? 0;
83817
- if (!list || itemCount < TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
83818
- break;
83819
- }
83820
- }
83821
- return "";
83822
- }
83823
83848
  async getGradeLevelTestResults(user, options) {
83824
83849
  return this.withClientTelemetry(async () => {
83825
83850
  const { gameId, courseId, studentId } = options;
@@ -83922,7 +83947,7 @@ class TimebackAdminService {
83922
83947
  }).then((page) => page.data) : [];
83923
83948
  const latestResultsByLineItem = latestAssessmentResultsByLineItem(questionResults);
83924
83949
  const testName = resolveGradeLevelTestName(parentResult, parentLineItem);
83925
- const qtiTestId = await this.resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem);
83950
+ const qtiTestId = await resolveQtiTestId(client, testName, parentLineItem);
83926
83951
  const qtiQuestions = qtiTestId ? await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => []) : [];
83927
83952
  const questions = mapGradeLevelReviewQuestions({
83928
83953
  questionLineItems,
@@ -84616,6 +84641,7 @@ var init_timeback_admin_service = __esm(async () => {
84616
84641
  init_timeback_discrepancy_queue_util();
84617
84642
  init_timeback_game_metrics_comparison_util();
84618
84643
  init_timeback_grade_level_results_util();
84644
+ init_timeback_qti_test_lookup_util();
84619
84645
  init_timeback_util();
84620
84646
  await __promiseAll([
84621
84647
  init_dist5(),
@@ -92622,6 +92648,128 @@ var init_timeback_create_integration_util = __esm(() => {
92622
92648
  init_timeback_util();
92623
92649
  });
92624
92650
 
92651
+ // ../api-core/src/utils/timeback-hole-filling-context.util.ts
92652
+ function buildAssignmentResultWhere(options) {
92653
+ return {
92654
+ status: "active",
92655
+ "student.sourcedId": options.studentId,
92656
+ "metadata.resultType": options.resultType,
92657
+ "metadata.assignmentId": options.assignmentId
92658
+ };
92659
+ }
92660
+ function assignmentIdOf(record) {
92661
+ const value = metadataOf(record).assignmentId;
92662
+ if (typeof value === "number" && Number.isFinite(value)) {
92663
+ return String(value);
92664
+ }
92665
+ const text = stringField3(value).trim();
92666
+ return text || undefined;
92667
+ }
92668
+ function toTriggeringResult(result) {
92669
+ const metadata2 = metadataOf(result);
92670
+ const testType = normalizeTestType(metadata2.testType);
92671
+ const assignmentId = assignmentIdOf(result);
92672
+ const lineItemId = sourceIdFromRef(result.assessmentLineItem);
92673
+ if (!assignmentId || !lineItemId || result.scoreStatus !== SCORE_STATUS2.fullyGraded || !isAllowedGradeLevelTestType(testType)) {
92674
+ return null;
92675
+ }
92676
+ return {
92677
+ resultId: result.sourcedId ?? "",
92678
+ assignmentId,
92679
+ lineItemId,
92680
+ testName: stringField3(metadata2.testName) || lineItemId,
92681
+ testType,
92682
+ score: numberField(result.score),
92683
+ scoreDate: result.scoreDate ?? null,
92684
+ totalQuestions: numberField(metadata2.totalQuestions),
92685
+ correctQuestions: numberField(metadata2.correctQuestions)
92686
+ };
92687
+ }
92688
+ function isMissed(resultText, score, scorePercentile) {
92689
+ const marking = stringField3(resultText).trim().toLowerCase();
92690
+ if (marking) {
92691
+ return marking !== "correct";
92692
+ }
92693
+ const value = normalizedQuestionScore(score, scorePercentile);
92694
+ return value !== null && value < 1;
92695
+ }
92696
+ function missedQuestionsFromInlineResponses(result) {
92697
+ const responses = metadataOf(result).responses;
92698
+ const questions = isRecord6(responses) && Array.isArray(responses.questions) ? responses.questions : undefined;
92699
+ if (!questions?.length) {
92700
+ return;
92701
+ }
92702
+ return questions.flatMap((question) => {
92703
+ if (!isRecord6(question) || !isMissed(question.result, question.score)) {
92704
+ return [];
92705
+ }
92706
+ const questionNumber = numberField(question.questionNumber);
92707
+ const qtiItemId = stringField3(question.questionQtiIdentifier).trim();
92708
+ return questionNumber === null ? [] : [{ questionNumber, ...qtiItemId && { qtiItemId } }];
92709
+ });
92710
+ }
92711
+ function questionNumberFromLineItemId(lineItemId) {
92712
+ const match = lineItemId.match(/_q(\d+)$/);
92713
+ const value = match ? Number(match[1]) : Number.NaN;
92714
+ return Number.isInteger(value) && value > 0 ? value : null;
92715
+ }
92716
+ function missedQuestionsFromQuestionResults(questionResults) {
92717
+ return [...latestAssessmentResultsByLineItem(questionResults)].flatMap(([lineItemId, result]) => {
92718
+ const questionNumber = questionNumberFromLineItemId(lineItemId);
92719
+ const responses = metadataOf(result).responses;
92720
+ const response = isRecord6(responses) && isRecord6(responses.response) ? responses.response : undefined;
92721
+ if (questionNumber === null || !isMissed(response?.result, result.score, result.scorePercentile)) {
92722
+ return [];
92723
+ }
92724
+ const qtiItemId = stringField3(response?.questionQtiIdentifier).trim();
92725
+ return [{ questionNumber, ...qtiItemId && { qtiItemId } }];
92726
+ }).toSorted((a, b) => a.questionNumber - b.questionNumber);
92727
+ }
92728
+ function qtiItemIdForQuestionNumber(qtiQuestions) {
92729
+ const byNumber = qtiQuestionsByIndex(qtiQuestions);
92730
+ return (questionNumber) => {
92731
+ const question = byNumber.get(questionNumber) ?? qtiQuestions[questionNumber - 1];
92732
+ return question ? question.question.identifier || question.reference.identifier : undefined;
92733
+ };
92734
+ }
92735
+ function groupMissedStandards(missed, standardsByItemId, itemIdFor) {
92736
+ const byKey = new Map;
92737
+ const untagged = [];
92738
+ for (const question of missed) {
92739
+ const itemId = question.qtiItemId ?? itemIdFor(question.questionNumber);
92740
+ const standards = itemId ? standardsByItemId.get(itemId) ?? [] : [];
92741
+ if (!standards.length) {
92742
+ untagged.push(question.questionNumber);
92743
+ }
92744
+ for (const standard of standards) {
92745
+ const key = `${standard.framework}\x00${standard.identifier}`;
92746
+ const entry = byKey.get(key) ?? {
92747
+ framework: standard.framework,
92748
+ code: standard.identifier,
92749
+ questionNumbers: [],
92750
+ qtiItemIds: []
92751
+ };
92752
+ entry.questionNumbers.push(question.questionNumber);
92753
+ if (itemId && !entry.qtiItemIds.includes(itemId)) {
92754
+ entry.qtiItemIds.push(itemId);
92755
+ }
92756
+ byKey.set(key, entry);
92757
+ }
92758
+ }
92759
+ return {
92760
+ missedStandards: [...byKey.values()].toSorted((a, b) => b.questionNumbers.length - a.questionNumbers.length || a.code.localeCompare(b.code, undefined, { numeric: true })),
92761
+ untaggedQuestionNumbers: untagged.toSorted((a, b) => a - b)
92762
+ };
92763
+ }
92764
+ function standardsFromQtiItem(item) {
92765
+ return qtiAssessmentStandardRefsFromMetadata2(metadataOf(item));
92766
+ }
92767
+ var init_timeback_hole_filling_context_util = __esm(() => {
92768
+ init_constants2();
92769
+ init_qti();
92770
+ init_timeback_grade_level_results_util();
92771
+ });
92772
+
92625
92773
  // ../api-core/src/utils/timeback-promotion.util.ts
92626
92774
  async function promoteCompletedCourse({
92627
92775
  db,
@@ -92751,14 +92899,20 @@ var init_timeback_service = __esm(async () => {
92751
92899
  init_helpers_index();
92752
92900
  init_tables_index();
92753
92901
  init_spans();
92902
+ init_constants2();
92754
92903
  init_types2();
92755
92904
  init_errors2();
92756
92905
  init_timeback_create_integration_util();
92906
+ init_timeback_grade_level_results_util();
92907
+ init_timeback_hole_filling_context_util();
92757
92908
  init_timeback_promotion_util();
92909
+ init_timeback_qti_test_lookup_util();
92758
92910
  init_timeback_removed_integration_util();
92759
92911
  init_timeback_util();
92760
92912
  await init_dist5();
92761
92913
  TimebackService = class TimebackService {
92914
+ static HOLE_FILLING_CONTEXT_TTL_MS = 10 * 60 * 1000;
92915
+ static HOLE_FILLING_CONTEXT_CACHE_SIZE = 1000;
92762
92916
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
92763
92917
  static processedHeartbeatWindows = new Map;
92764
92918
  static inFlightHeartbeatWindows = new Map;
@@ -92766,6 +92920,7 @@ var init_timeback_service = __esm(async () => {
92766
92920
  static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
92767
92921
  static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
92768
92922
  deps;
92923
+ holeFillingContextCache = new Map;
92769
92924
  static cleanHeartbeatDedupeCache(now = Date.now()) {
92770
92925
  for (const [key, timestamp] of this.processedHeartbeatWindows) {
92771
92926
  if (now - timestamp > this.HEARTBEAT_DEDUPE_TTL_MS) {
@@ -93139,19 +93294,163 @@ var init_timeback_service = __esm(async () => {
93139
93294
  }
93140
93295
  async getUserEnrollments(userId, gameId) {
93141
93296
  return this.withClientTelemetry(async () => {
93142
- const userData = await this.deps.db.query.users.findFirst({
93143
- where: eq(users.id, userId),
93144
- columns: { id: true, timebackId: true }
93145
- });
93146
- if (!userData) {
93147
- throw new NotFoundError("User", userId);
93297
+ const enrollments = await this.fetchEnrollments(await this.requireTimebackId(userId));
93298
+ return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
93299
+ });
93300
+ }
93301
+ async requireTimebackId(userId) {
93302
+ const userData = await this.deps.db.query.users.findFirst({
93303
+ where: eq(users.id, userId),
93304
+ columns: { id: true, timebackId: true }
93305
+ });
93306
+ if (!userData) {
93307
+ throw new NotFoundError("User", userId);
93308
+ }
93309
+ if (!userData.timebackId) {
93310
+ throw new NotFoundError("Timeback account not found for user");
93311
+ }
93312
+ return userData.timebackId;
93313
+ }
93314
+ async getHoleFillingContext(userId, gameId) {
93315
+ return this.withClientTelemetry(async () => {
93316
+ setAttribute("app.timeback.operation", "get_hole_filling_context");
93317
+ const studentId = await this.requireTimebackId(userId);
93318
+ const enrollments = filterEnrollmentsByGame(await this.fetchEnrollments(studentId), gameId);
93319
+ const holeFilling = enrollments.filter((enrollment) => enrollment.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND);
93320
+ setAttribute("app.timeback.hole_filling_enrollment_count", holeFilling.length);
93321
+ if (holeFilling.length === 0) {
93322
+ return { contexts: [] };
93148
93323
  }
93149
- if (!userData.timebackId) {
93150
- throw new NotFoundError("Timeback account not found for user");
93324
+ const contexts = await Promise.all(holeFilling.map((enrollment) => this.buildHoleFillingContext(studentId, enrollment)));
93325
+ return { contexts };
93326
+ });
93327
+ }
93328
+ async buildHoleFillingContext(studentId, enrollment) {
93329
+ const client = this.requireClient();
93330
+ const grade = enrollment.grade;
93331
+ const subject = enrollment.subject;
93332
+ const enrollmentContext = {
93333
+ courseId: enrollment.courseId,
93334
+ baseCourseId: enrollment.baseCourseId ?? "",
93335
+ ...enrollment.id && { enrollmentId: enrollment.id },
93336
+ grade,
93337
+ subject,
93338
+ beginDate: enrollment.beginDate ?? null
93339
+ };
93340
+ const stampedAssignmentId = assignmentIdOf({ metadata: enrollment.enrollmentMetadata });
93341
+ let parent = null;
93342
+ if (stampedAssignmentId) {
93343
+ const where = {
93344
+ ...buildAssignmentResultWhere({
93345
+ studentId,
93346
+ assignmentId: stampedAssignmentId,
93347
+ resultType: "assessment"
93348
+ }),
93349
+ scoreStatus: SCORE_STATUS2.fullyGraded
93350
+ };
93351
+ const page = await client.api.oneroster.assessmentResults.list({
93352
+ limit: 1,
93353
+ where,
93354
+ sort: "scoreDate",
93355
+ orderBy: "desc"
93356
+ });
93357
+ parent = page.data[0] ?? null;
93358
+ }
93359
+ const summary = parent ? toTriggeringResult(parent) : null;
93360
+ let trigger = "not_stamped";
93361
+ if (stampedAssignmentId) {
93362
+ trigger = summary ? "found" : "not_found";
93363
+ }
93364
+ setAttributes({
93365
+ "app.timeback.hole_filling_trigger": trigger,
93366
+ "app.timeback.hole_filling_assignment_id": stampedAssignmentId
93367
+ });
93368
+ if (!parent || !summary) {
93369
+ return {
93370
+ enrollment: enrollmentContext,
93371
+ triggeringResult: null,
93372
+ missedStandards: [],
93373
+ untaggedMissedQuestionNumbers: []
93374
+ };
93375
+ }
93376
+ const cacheKey = `${enrollment.courseId}:${studentId}:${summary.resultId}`;
93377
+ const cached = this.holeFillingContextCache.get(cacheKey);
93378
+ if (cached && Date.now() - cached.at < TimebackService.HOLE_FILLING_CONTEXT_TTL_MS) {
93379
+ return { ...cached.context, enrollment: enrollmentContext };
93380
+ }
93381
+ const { context, complete } = await this.resolveMissedStandards(studentId, parent, summary, enrollmentContext);
93382
+ setAttribute("app.timeback.hole_filling_standards_complete", complete);
93383
+ if (complete) {
93384
+ setBounded(this.holeFillingContextCache, cacheKey, { at: Date.now(), context }, TimebackService.HOLE_FILLING_CONTEXT_CACHE_SIZE);
93385
+ }
93386
+ return context;
93387
+ }
93388
+ async resolveMissedStandards(studentId, parent, summary, enrollmentContext) {
93389
+ const client = this.requireClient();
93390
+ let complete = true;
93391
+ let missed = missedQuestionsFromInlineResponses(parent);
93392
+ if (!missed) {
93393
+ const where = buildAssignmentResultWhere({
93394
+ studentId,
93395
+ assignmentId: summary.assignmentId,
93396
+ resultType: "question"
93397
+ });
93398
+ const questionResults = await client.api.oneroster.assessmentResults.listAll({
93399
+ limit: 200,
93400
+ where,
93401
+ sort: "scoreDate",
93402
+ orderBy: "desc"
93403
+ });
93404
+ missed = missedQuestionsFromQuestionResults(questionResults);
93405
+ }
93406
+ let qtiTestId = "";
93407
+ let qtiQuestions = [];
93408
+ const standardsByItemId = new Map;
93409
+ if (missed.length > 0 && !client.hasQti) {
93410
+ complete = false;
93411
+ missed = missed.map(({ questionNumber }) => ({ questionNumber }));
93412
+ } else if (missed.some((question) => !question.qtiItemId)) {
93413
+ const lineItem = await client.api.oneroster.assessmentLineItems.get(summary.lineItemId).catch(() => null);
93414
+ qtiTestId = lineItem ? await resolveQtiTestId(client, resolveGradeLevelTestName(parent, lineItem), lineItem) : "";
93415
+ complete &&= Boolean(qtiTestId);
93416
+ if (qtiTestId) {
93417
+ qtiQuestions = await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => {
93418
+ complete = false;
93419
+ return [];
93420
+ });
93421
+ for (const question of qtiQuestions) {
93422
+ const id = question.question.identifier || question.reference.identifier;
93423
+ if (id) {
93424
+ standardsByItemId.set(id, standardsFromQtiItem(question.question));
93425
+ }
93426
+ }
93151
93427
  }
93152
- const enrollments = await this.fetchEnrollments(userData.timebackId);
93153
- return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
93428
+ }
93429
+ const itemIdsToFetch = [
93430
+ ...new Set(missed.flatMap((question) => question.qtiItemId && !standardsByItemId.has(question.qtiItemId) ? [question.qtiItemId] : []))
93431
+ ];
93432
+ const items = await Promise.all(itemIdsToFetch.map((id) => client.qtiApi.assessmentItems.get(id).then((item) => [id, standardsFromQtiItem(item)]).catch(() => {
93433
+ complete = false;
93434
+ return [id, []];
93435
+ })));
93436
+ for (const [id, standards] of items) {
93437
+ standardsByItemId.set(id, standards);
93438
+ }
93439
+ const { missedStandards, untaggedQuestionNumbers } = groupMissedStandards(missed, standardsByItemId, qtiItemIdForQuestionNumber(qtiQuestions));
93440
+ setAttributes({
93441
+ "app.timeback.hole_filling_missed_question_count": missed.length,
93442
+ "app.timeback.hole_filling_missed_standard_count": missedStandards.length,
93443
+ "app.timeback.hole_filling_untagged_question_count": untaggedQuestionNumbers.length
93154
93444
  });
93445
+ return {
93446
+ context: {
93447
+ enrollment: enrollmentContext,
93448
+ triggeringResult: { ...summary, ...qtiTestId && { qtiTestId } },
93449
+ missedStandards,
93450
+ untaggedMissedQuestionNumbers: untaggedQuestionNumbers
93451
+ },
93452
+ complete
93453
+ };
93155
93454
  }
93156
93455
  async getUserDataByTimebackId(timebackId) {
93157
93456
  return this.withClientTelemetry(async () => {
@@ -96711,33 +97010,33 @@ var init_auth_provider = __esm(async () => {
96711
97010
  function createSandboxCacheProvider() {
96712
97011
  return {
96713
97012
  async get(key) {
96714
- const entry = cache.get(key);
97013
+ const entry = cache2.get(key);
96715
97014
  if (!entry) {
96716
97015
  return null;
96717
97016
  }
96718
97017
  if (entry.expiresAt < Date.now()) {
96719
- cache.delete(key);
97018
+ cache2.delete(key);
96720
97019
  return null;
96721
97020
  }
96722
97021
  return entry.value;
96723
97022
  },
96724
97023
  async set(key, value, ttlSeconds = 300) {
96725
- cache.set(key, {
97024
+ cache2.set(key, {
96726
97025
  value,
96727
97026
  expiresAt: Date.now() + ttlSeconds * 1000
96728
97027
  });
96729
97028
  },
96730
97029
  async delete(key) {
96731
- cache.delete(key);
97030
+ cache2.delete(key);
96732
97031
  }
96733
97032
  };
96734
97033
  }
96735
97034
  function clearSandboxCache() {
96736
- cache.clear();
97035
+ cache2.clear();
96737
97036
  }
96738
- var cache;
97037
+ var cache2;
96739
97038
  var init_cache_provider = __esm(() => {
96740
- cache = new Map;
97039
+ cache2 = new Map;
96741
97040
  });
96742
97041
 
96743
97042
  // ../utils/src/keys.ts
@@ -152965,7 +153264,7 @@ function parseQtiLibraryParams(searchParams) {
152965
153264
  limit
152966
153265
  };
152967
153266
  }
152968
- var populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, getHoleFillingCourses, createHoleFillingCourse, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
153267
+ var populateStudent, getUser, getUserEnrollments, getUserHoleFillingContext, getUserById, setupIntegration, getIntegrations, getRemovedIntegrations, createIntegration, getHoleFillingCourses, createHoleFillingCourse, updateIntegration, deactivateCourse, reactivateCourse, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, unenrollCourse, prepareRuntimeAssessment, startRuntimeAssessment, getRuntimeAssessment, getLatestRuntimeAssessment, resumeRuntimeAssessment, saveRuntimeAssessment, recordRuntimeAssessmentProgress, submitRuntimeAssessmentItem, submitRuntimeAssessment, finalizeRuntimeAssessment, exportRuntimeAssessmentFixture, getStudentXp, getStudentMastery, getStudentHighestGradeMastered, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getGradeLevelTestResults, getGradeLevelTestReview, getActivityDetail, listMetricDiscrepancies, verifyMetricDiscrepancy, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, attachExistingAssessments, updateAssessment, reviseAssessment, synchronizeReviewBank, reorderQuestions, removeAssessment, listQuestions, listQuestionLibrary, listTestLibrary, copyAssessment, createQuestion, updateQuestion, removeQuestion, timeback2;
152969
153268
  var init_timeback_controller = __esm(() => {
152970
153269
  init_esm();
152971
153270
  init_src();
@@ -152991,6 +153290,12 @@ var init_timeback_controller = __esm(() => {
152991
153290
  });
152992
153291
  getUser = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserData(ctx.user.id, ctx.gameId));
152993
153292
  getUserEnrollments = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserEnrollments(ctx.user.id, ctx.gameId));
153293
+ getUserHoleFillingContext = requireNonAnonymous(async (ctx) => {
153294
+ if (!ctx.gameId) {
153295
+ throw ApiError.forbidden("Hole-filling context requires a game token");
153296
+ }
153297
+ return ctx.services.timeback.getHoleFillingContext(ctx.user.id, ctx.gameId);
153298
+ });
152994
153299
  getUserById = requireNonAnonymous(async (ctx) => {
152995
153300
  const timebackId = ctx.params.timebackId;
152996
153301
  if (!timebackId) {
@@ -153844,6 +154149,7 @@ var init_timeback_controller = __esm(() => {
153844
154149
  populateStudent,
153845
154150
  getUser,
153846
154151
  getUserEnrollments,
154152
+ getUserHoleFillingContext,
153847
154153
  getUserById,
153848
154154
  setupIntegration,
153849
154155
  getIntegrations,
@@ -154906,6 +155212,12 @@ var init_timeback8 = __esm(async () => {
154906
155212
  }
154907
155213
  return handle2(timeback2.getUserEnrollments)(c);
154908
155214
  });
155215
+ timebackRouter.get("/user/hole-filling-context", async (c) => {
155216
+ if (shouldMockTimeback()) {
155217
+ return c.json({ contexts: [] });
155218
+ }
155219
+ return handle2(timeback2.getUserHoleFillingContext)(c);
155220
+ });
154909
155221
  timebackRouter.get("/user/:timebackId", async (c) => {
154910
155222
  const user = c.get("user");
154911
155223
  if (!user) {