@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/server.js CHANGED
@@ -697,7 +697,7 @@ var package_default;
697
697
  var init_package = __esm(() => {
698
698
  package_default = {
699
699
  name: "@playcademy/sandbox",
700
- version: "0.12.2-beta.3",
700
+ version: "0.12.2-beta.4",
701
701
  description: "Local development server for Playcademy game development",
702
702
  type: "module",
703
703
  exports: {
@@ -80045,6 +80045,9 @@ function normalizedIdentityCase2(value) {
80045
80045
  function frameworkAliasKey2(value) {
80046
80046
  return value.normalize("NFKC").toLocaleLowerCase("en-US").replace(/[^a-z0-9]+/g, "");
80047
80047
  }
80048
+ function isUuidShaped2(value) {
80049
+ return UUID_PATTERN2.test(value.trim());
80050
+ }
80048
80051
  function isCommonCoreMathIdentifier2(identifier) {
80049
80052
  const canonical = normalizedIdentityCase2(normalizedWhitespace2(identifier));
80050
80053
  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);
@@ -80076,6 +80079,14 @@ function canonicalIdentifier2(framework, authoredIdentifier) {
80076
80079
  }
80077
80080
  return identifier;
80078
80081
  }
80082
+ function readableAlignmentIdentifier2(alignment) {
80083
+ const identifier = alignment.identifier?.trim() ?? "";
80084
+ if (identifier && !isUuidShaped2(identifier)) {
80085
+ return identifier;
80086
+ }
80087
+ const id = alignment.id.trim();
80088
+ return id && !isUuidShaped2(id) ? id : "";
80089
+ }
80079
80090
  function canonicalAssessmentStandardRef2(standard) {
80080
80091
  const authoredFramework = normalizedWhitespace2(standard.framework);
80081
80092
  const framework = canonicalFramework2(authoredFramework, standard.identifier);
@@ -80182,7 +80193,23 @@ function qtiStandardsFromMetadata(metadata2) {
80182
80193
  standards.push(...qtiAlignmentStandardsFromMetadata2(metadata2));
80183
80194
  return dedupeStandards2(standards);
80184
80195
  }
80185
- 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;
80196
+ function qtiAssessmentStandardRefsFromMetadata2(metadata2) {
80197
+ const refs = new Map;
80198
+ for (const alignment of qtiAlignmentStandardsFromMetadata2(metadata2)) {
80199
+ const identifier = readableAlignmentIdentifier2(alignment);
80200
+ if (identifier) {
80201
+ const canonical = canonicalAssessmentStandardRef2({
80202
+ framework: alignment.source,
80203
+ identifier
80204
+ });
80205
+ if (canonical.framework && canonical.identifier) {
80206
+ refs.set(assessmentStandardRefKey2(canonical), canonical);
80207
+ }
80208
+ }
80209
+ }
80210
+ return [...refs.values()];
80211
+ }
80212
+ 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;
80186
80213
  var init_qti = __esm(() => {
80187
80214
  init_timeback4();
80188
80215
  init_timeback4();
@@ -80544,6 +80571,7 @@ var init_qti = __esm(() => {
80544
80571
  this.name = "QtiScoringArtifactValidationError";
80545
80572
  }
80546
80573
  };
80574
+ 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;
80547
80575
  COMMON_CORE_MATH_FRAMEWORK_ALIASES2 = new Set([
80548
80576
  "ccssm",
80549
80577
  "ccssmath",
@@ -80753,7 +80781,7 @@ function pruneUnreadableStandards(standards) {
80753
80781
  return standards.filter(hasReadableStandardLabel);
80754
80782
  }
80755
80783
  function parseQuestionIndex(title) {
80756
- const match = title.match(/\bquestion[\s_-]*(\d+)\b/i);
80784
+ const match = title?.match(/\bquestion[\s_-]*(\d+)\b/i);
80757
80785
  const index = match ? Number(match[1]) : Number.NaN;
80758
80786
  return Number.isFinite(index) && index > 0 ? index : null;
80759
80787
  }
@@ -80787,14 +80815,15 @@ function qtiQuestionsByIndex(qtiQuestions) {
80787
80815
  }
80788
80816
  return questionsByIndex;
80789
80817
  }
80818
+ function normalizedQuestionScore(score, scorePercentile) {
80819
+ const rawValue = numberField(score) ?? numberField(scorePercentile);
80820
+ return rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80821
+ }
80790
80822
  function getQuestionCorrectness(result) {
80791
80823
  if (!result) {
80792
80824
  return "unknown";
80793
80825
  }
80794
- const score = numberField(result.score);
80795
- const scorePercentile = numberField(result.scorePercentile);
80796
- const rawValue = score ?? scorePercentile;
80797
- const value = rawValue !== null && rawValue > 1 ? rawValue / 100 : rawValue;
80826
+ const value = normalizedQuestionScore(result.score, result.scorePercentile);
80798
80827
  if (value === null) {
80799
80828
  return "unknown";
80800
80829
  }
@@ -81058,10 +81087,71 @@ var init_timeback_mastery_completion_util = __esm(async () => {
81058
81087
  await init_errors8();
81059
81088
  });
81060
81089
 
81090
+ // ../api-core/src/utils/bounded-map.util.ts
81091
+ function setBounded(map, key, value, maxSize) {
81092
+ map.delete(key);
81093
+ if (map.size >= maxSize) {
81094
+ const oldestKey = map.keys().next().value;
81095
+ if (oldestKey !== undefined) {
81096
+ map.delete(oldestKey);
81097
+ }
81098
+ }
81099
+ map.set(key, value);
81100
+ }
81101
+
81102
+ // ../api-core/src/utils/timeback-qti-test-lookup.util.ts
81103
+ async function resolveQtiTestId(client, testName, parentLineItem) {
81104
+ const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81105
+ const cached = cache.get(cacheKey);
81106
+ if (cached) {
81107
+ setBounded(cache, cacheKey, cached, MAX_CACHE_SIZE);
81108
+ return cached;
81109
+ }
81110
+ let resource = null;
81111
+ const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81112
+ if (resourceId) {
81113
+ resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81114
+ }
81115
+ for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81116
+ const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81117
+ if (test?.identifier) {
81118
+ setBounded(cache, cacheKey, test.identifier, MAX_CACHE_SIZE);
81119
+ return test.identifier;
81120
+ }
81121
+ }
81122
+ const normalizedTestName = testName.trim();
81123
+ if (!normalizedTestName) {
81124
+ return "";
81125
+ }
81126
+ for (let page = 1;page <= TITLE_LOOKUP_MAX_PAGES; page++) {
81127
+ const list = await client.qtiApi.assessmentTests.list({
81128
+ query: normalizedTestName,
81129
+ limit: TITLE_LOOKUP_LIMIT,
81130
+ page,
81131
+ sort: "title",
81132
+ order: "asc"
81133
+ }).catch(() => null);
81134
+ const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81135
+ if (qtiTestId) {
81136
+ setBounded(cache, cacheKey, qtiTestId, MAX_CACHE_SIZE);
81137
+ return qtiTestId;
81138
+ }
81139
+ const itemCount = list?.items?.length ?? 0;
81140
+ if (!list || itemCount < TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81141
+ break;
81142
+ }
81143
+ }
81144
+ return "";
81145
+ }
81146
+ var MAX_CACHE_SIZE = 500, TITLE_LOOKUP_LIMIT = 50, TITLE_LOOKUP_MAX_PAGES = 5, cache;
81147
+ var init_timeback_qti_test_lookup_util = __esm(() => {
81148
+ init_timeback_grade_level_results_util();
81149
+ cache = new Map;
81150
+ });
81151
+
81061
81152
  // ../api-core/src/services/timeback-admin.service.ts
81062
81153
  class TimebackAdminService {
81063
81154
  deps;
81064
- qtiTestIdCache = new Map;
81065
81155
  static XP_PRECISION_FACTOR = 10;
81066
81156
  static RECENT_ACTIVITY_LIMIT = 20;
81067
81157
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
@@ -81076,9 +81166,6 @@ class TimebackAdminService {
81076
81166
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
81077
81167
  static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
81078
81168
  static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
81079
- static MAX_QTI_TEST_ID_CACHE_SIZE = 500;
81080
- static QTI_TEST_TITLE_LOOKUP_LIMIT = 50;
81081
- static QTI_TEST_TITLE_LOOKUP_MAX_PAGES = 5;
81082
81169
  constructor(deps) {
81083
81170
  this.deps = deps;
81084
81171
  }
@@ -81938,68 +82025,6 @@ class TimebackAdminService {
81938
82025
  return { activities: activitiesWithGameMetrics, hasMore };
81939
82026
  });
81940
82027
  }
81941
- rememberQtiTestId(cacheKey, qtiTestId) {
81942
- if (!qtiTestId) {
81943
- this.qtiTestIdCache.delete(cacheKey);
81944
- return;
81945
- }
81946
- if (this.qtiTestIdCache.has(cacheKey)) {
81947
- this.qtiTestIdCache.delete(cacheKey);
81948
- } else if (this.qtiTestIdCache.size >= TimebackAdminService.MAX_QTI_TEST_ID_CACHE_SIZE) {
81949
- const oldestKey = this.qtiTestIdCache.keys().next().value;
81950
- if (oldestKey !== undefined) {
81951
- this.qtiTestIdCache.delete(oldestKey);
81952
- }
81953
- }
81954
- this.qtiTestIdCache.set(cacheKey, qtiTestId);
81955
- }
81956
- async resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem) {
81957
- const cacheKey = `${parentLineItem.sourcedId ?? ""}:${testName}`;
81958
- const cachedQtiTestId = cacheKey ? this.qtiTestIdCache.get(cacheKey) : undefined;
81959
- if (cachedQtiTestId) {
81960
- this.rememberQtiTestId(cacheKey, cachedQtiTestId);
81961
- return cachedQtiTestId;
81962
- }
81963
- let resource = null;
81964
- const resourceId = sourceIdFromRef(parentLineItem.componentResource);
81965
- if (resourceId) {
81966
- resource = await client.api.oneroster.resources.get(resourceId).catch(() => null);
81967
- }
81968
- for (const candidate of qtiIdCandidates(parentLineItem, resource)) {
81969
- const test = await client.qtiApi.assessmentTests.get(candidate).catch(() => null);
81970
- if (test?.identifier) {
81971
- if (cacheKey) {
81972
- this.rememberQtiTestId(cacheKey, test.identifier);
81973
- }
81974
- return test.identifier;
81975
- }
81976
- }
81977
- const normalizedTestName = testName.trim();
81978
- if (!normalizedTestName) {
81979
- return "";
81980
- }
81981
- for (let page = 1;page <= TimebackAdminService.QTI_TEST_TITLE_LOOKUP_MAX_PAGES; page++) {
81982
- const list = await client.qtiApi.assessmentTests.list({
81983
- query: normalizedTestName,
81984
- limit: TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT,
81985
- page,
81986
- sort: "title",
81987
- order: "asc"
81988
- }).catch(() => null);
81989
- const qtiTestId = findQtiAssessmentTestIdByTitle(list?.items ?? [], normalizedTestName);
81990
- if (qtiTestId) {
81991
- if (cacheKey) {
81992
- this.rememberQtiTestId(cacheKey, qtiTestId);
81993
- }
81994
- return qtiTestId;
81995
- }
81996
- const itemCount = list?.items?.length ?? 0;
81997
- if (!list || itemCount < TimebackAdminService.QTI_TEST_TITLE_LOOKUP_LIMIT || typeof list.pages === "number" && page >= list.pages) {
81998
- break;
81999
- }
82000
- }
82001
- return "";
82002
- }
82003
82028
  async getGradeLevelTestResults(user, options) {
82004
82029
  return this.withClientTelemetry(async () => {
82005
82030
  const { gameId, courseId, studentId } = options;
@@ -82102,7 +82127,7 @@ class TimebackAdminService {
82102
82127
  }).then((page) => page.data) : [];
82103
82128
  const latestResultsByLineItem = latestAssessmentResultsByLineItem(questionResults);
82104
82129
  const testName = resolveGradeLevelTestName(parentResult, parentLineItem);
82105
- const qtiTestId = await this.resolveQtiTestIdForGradeLevelResult(client, testName, parentLineItem);
82130
+ const qtiTestId = await resolveQtiTestId(client, testName, parentLineItem);
82106
82131
  const qtiQuestions = qtiTestId ? await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => []) : [];
82107
82132
  const questions = mapGradeLevelReviewQuestions({
82108
82133
  questionLineItems,
@@ -82796,6 +82821,7 @@ var init_timeback_admin_service = __esm(async () => {
82796
82821
  init_timeback_discrepancy_queue_util();
82797
82822
  init_timeback_game_metrics_comparison_util();
82798
82823
  init_timeback_grade_level_results_util();
82824
+ init_timeback_qti_test_lookup_util();
82799
82825
  init_timeback_util();
82800
82826
  await __promiseAll([
82801
82827
  init_dist5(),
@@ -90802,6 +90828,128 @@ var init_timeback_create_integration_util = __esm(() => {
90802
90828
  init_timeback_util();
90803
90829
  });
90804
90830
 
90831
+ // ../api-core/src/utils/timeback-hole-filling-context.util.ts
90832
+ function buildAssignmentResultWhere(options) {
90833
+ return {
90834
+ status: "active",
90835
+ "student.sourcedId": options.studentId,
90836
+ "metadata.resultType": options.resultType,
90837
+ "metadata.assignmentId": options.assignmentId
90838
+ };
90839
+ }
90840
+ function assignmentIdOf(record) {
90841
+ const value = metadataOf(record).assignmentId;
90842
+ if (typeof value === "number" && Number.isFinite(value)) {
90843
+ return String(value);
90844
+ }
90845
+ const text = stringField3(value).trim();
90846
+ return text || undefined;
90847
+ }
90848
+ function toTriggeringResult(result) {
90849
+ const metadata2 = metadataOf(result);
90850
+ const testType = normalizeTestType(metadata2.testType);
90851
+ const assignmentId = assignmentIdOf(result);
90852
+ const lineItemId = sourceIdFromRef(result.assessmentLineItem);
90853
+ if (!assignmentId || !lineItemId || result.scoreStatus !== SCORE_STATUS2.fullyGraded || !isAllowedGradeLevelTestType(testType)) {
90854
+ return null;
90855
+ }
90856
+ return {
90857
+ resultId: result.sourcedId ?? "",
90858
+ assignmentId,
90859
+ lineItemId,
90860
+ testName: stringField3(metadata2.testName) || lineItemId,
90861
+ testType,
90862
+ score: numberField(result.score),
90863
+ scoreDate: result.scoreDate ?? null,
90864
+ totalQuestions: numberField(metadata2.totalQuestions),
90865
+ correctQuestions: numberField(metadata2.correctQuestions)
90866
+ };
90867
+ }
90868
+ function isMissed(resultText, score, scorePercentile) {
90869
+ const marking = stringField3(resultText).trim().toLowerCase();
90870
+ if (marking) {
90871
+ return marking !== "correct";
90872
+ }
90873
+ const value = normalizedQuestionScore(score, scorePercentile);
90874
+ return value !== null && value < 1;
90875
+ }
90876
+ function missedQuestionsFromInlineResponses(result) {
90877
+ const responses = metadataOf(result).responses;
90878
+ const questions = isRecord6(responses) && Array.isArray(responses.questions) ? responses.questions : undefined;
90879
+ if (!questions?.length) {
90880
+ return;
90881
+ }
90882
+ return questions.flatMap((question) => {
90883
+ if (!isRecord6(question) || !isMissed(question.result, question.score)) {
90884
+ return [];
90885
+ }
90886
+ const questionNumber = numberField(question.questionNumber);
90887
+ const qtiItemId = stringField3(question.questionQtiIdentifier).trim();
90888
+ return questionNumber === null ? [] : [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90889
+ });
90890
+ }
90891
+ function questionNumberFromLineItemId(lineItemId) {
90892
+ const match = lineItemId.match(/_q(\d+)$/);
90893
+ const value = match ? Number(match[1]) : Number.NaN;
90894
+ return Number.isInteger(value) && value > 0 ? value : null;
90895
+ }
90896
+ function missedQuestionsFromQuestionResults(questionResults) {
90897
+ return [...latestAssessmentResultsByLineItem(questionResults)].flatMap(([lineItemId, result]) => {
90898
+ const questionNumber = questionNumberFromLineItemId(lineItemId);
90899
+ const responses = metadataOf(result).responses;
90900
+ const response = isRecord6(responses) && isRecord6(responses.response) ? responses.response : undefined;
90901
+ if (questionNumber === null || !isMissed(response?.result, result.score, result.scorePercentile)) {
90902
+ return [];
90903
+ }
90904
+ const qtiItemId = stringField3(response?.questionQtiIdentifier).trim();
90905
+ return [{ questionNumber, ...qtiItemId && { qtiItemId } }];
90906
+ }).toSorted((a, b) => a.questionNumber - b.questionNumber);
90907
+ }
90908
+ function qtiItemIdForQuestionNumber(qtiQuestions) {
90909
+ const byNumber = qtiQuestionsByIndex(qtiQuestions);
90910
+ return (questionNumber) => {
90911
+ const question = byNumber.get(questionNumber) ?? qtiQuestions[questionNumber - 1];
90912
+ return question ? question.question.identifier || question.reference.identifier : undefined;
90913
+ };
90914
+ }
90915
+ function groupMissedStandards(missed, standardsByItemId, itemIdFor) {
90916
+ const byKey = new Map;
90917
+ const untagged = [];
90918
+ for (const question of missed) {
90919
+ const itemId = question.qtiItemId ?? itemIdFor(question.questionNumber);
90920
+ const standards = itemId ? standardsByItemId.get(itemId) ?? [] : [];
90921
+ if (!standards.length) {
90922
+ untagged.push(question.questionNumber);
90923
+ }
90924
+ for (const standard of standards) {
90925
+ const key = `${standard.framework}\x00${standard.identifier}`;
90926
+ const entry = byKey.get(key) ?? {
90927
+ framework: standard.framework,
90928
+ code: standard.identifier,
90929
+ questionNumbers: [],
90930
+ qtiItemIds: []
90931
+ };
90932
+ entry.questionNumbers.push(question.questionNumber);
90933
+ if (itemId && !entry.qtiItemIds.includes(itemId)) {
90934
+ entry.qtiItemIds.push(itemId);
90935
+ }
90936
+ byKey.set(key, entry);
90937
+ }
90938
+ }
90939
+ return {
90940
+ missedStandards: [...byKey.values()].toSorted((a, b) => b.questionNumbers.length - a.questionNumbers.length || a.code.localeCompare(b.code, undefined, { numeric: true })),
90941
+ untaggedQuestionNumbers: untagged.toSorted((a, b) => a - b)
90942
+ };
90943
+ }
90944
+ function standardsFromQtiItem(item) {
90945
+ return qtiAssessmentStandardRefsFromMetadata2(metadataOf(item));
90946
+ }
90947
+ var init_timeback_hole_filling_context_util = __esm(() => {
90948
+ init_constants2();
90949
+ init_qti();
90950
+ init_timeback_grade_level_results_util();
90951
+ });
90952
+
90805
90953
  // ../api-core/src/utils/timeback-promotion.util.ts
90806
90954
  async function promoteCompletedCourse({
90807
90955
  db,
@@ -90931,14 +91079,20 @@ var init_timeback_service = __esm(async () => {
90931
91079
  init_helpers_index();
90932
91080
  init_tables_index();
90933
91081
  init_spans();
91082
+ init_constants2();
90934
91083
  init_types2();
90935
91084
  init_errors2();
90936
91085
  init_timeback_create_integration_util();
91086
+ init_timeback_grade_level_results_util();
91087
+ init_timeback_hole_filling_context_util();
90937
91088
  init_timeback_promotion_util();
91089
+ init_timeback_qti_test_lookup_util();
90938
91090
  init_timeback_removed_integration_util();
90939
91091
  init_timeback_util();
90940
91092
  await init_dist5();
90941
91093
  TimebackService = class TimebackService {
91094
+ static HOLE_FILLING_CONTEXT_TTL_MS = 10 * 60 * 1000;
91095
+ static HOLE_FILLING_CONTEXT_CACHE_SIZE = 1000;
90942
91096
  static HEARTBEAT_DEDUPE_TTL_MS = 5 * 60 * 1000;
90943
91097
  static processedHeartbeatWindows = new Map;
90944
91098
  static inFlightHeartbeatWindows = new Map;
@@ -90946,6 +91100,7 @@ var init_timeback_service = __esm(async () => {
90946
91100
  static COMPLETION_IN_FLIGHT_SETTLE_DELAY_MS = 400;
90947
91101
  static COMPLETION_IN_FLIGHT_SETTLE_ATTEMPTS = 5;
90948
91102
  deps;
91103
+ holeFillingContextCache = new Map;
90949
91104
  static cleanHeartbeatDedupeCache(now = Date.now()) {
90950
91105
  for (const [key, timestamp] of this.processedHeartbeatWindows) {
90951
91106
  if (now - timestamp > this.HEARTBEAT_DEDUPE_TTL_MS) {
@@ -91319,19 +91474,163 @@ var init_timeback_service = __esm(async () => {
91319
91474
  }
91320
91475
  async getUserEnrollments(userId, gameId) {
91321
91476
  return this.withClientTelemetry(async () => {
91322
- const userData = await this.deps.db.query.users.findFirst({
91323
- where: eq(users.id, userId),
91324
- columns: { id: true, timebackId: true }
91325
- });
91326
- if (!userData) {
91327
- throw new NotFoundError("User", userId);
91477
+ const enrollments = await this.fetchEnrollments(await this.requireTimebackId(userId));
91478
+ return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91479
+ });
91480
+ }
91481
+ async requireTimebackId(userId) {
91482
+ const userData = await this.deps.db.query.users.findFirst({
91483
+ where: eq(users.id, userId),
91484
+ columns: { id: true, timebackId: true }
91485
+ });
91486
+ if (!userData) {
91487
+ throw new NotFoundError("User", userId);
91488
+ }
91489
+ if (!userData.timebackId) {
91490
+ throw new NotFoundError("Timeback account not found for user");
91491
+ }
91492
+ return userData.timebackId;
91493
+ }
91494
+ async getHoleFillingContext(userId, gameId) {
91495
+ return this.withClientTelemetry(async () => {
91496
+ setAttribute("app.timeback.operation", "get_hole_filling_context");
91497
+ const studentId = await this.requireTimebackId(userId);
91498
+ const enrollments = filterEnrollmentsByGame(await this.fetchEnrollments(studentId), gameId);
91499
+ const holeFilling = enrollments.filter((enrollment) => enrollment.kind === HOLE_FILLING_GAME_TIMEBACK_INTEGRATION_KIND);
91500
+ setAttribute("app.timeback.hole_filling_enrollment_count", holeFilling.length);
91501
+ if (holeFilling.length === 0) {
91502
+ return { contexts: [] };
91328
91503
  }
91329
- if (!userData.timebackId) {
91330
- throw new NotFoundError("Timeback account not found for user");
91504
+ const contexts = await Promise.all(holeFilling.map((enrollment) => this.buildHoleFillingContext(studentId, enrollment)));
91505
+ return { contexts };
91506
+ });
91507
+ }
91508
+ async buildHoleFillingContext(studentId, enrollment) {
91509
+ const client = this.requireClient();
91510
+ const grade = enrollment.grade;
91511
+ const subject = enrollment.subject;
91512
+ const enrollmentContext = {
91513
+ courseId: enrollment.courseId,
91514
+ baseCourseId: enrollment.baseCourseId ?? "",
91515
+ ...enrollment.id && { enrollmentId: enrollment.id },
91516
+ grade,
91517
+ subject,
91518
+ beginDate: enrollment.beginDate ?? null
91519
+ };
91520
+ const stampedAssignmentId = assignmentIdOf({ metadata: enrollment.enrollmentMetadata });
91521
+ let parent = null;
91522
+ if (stampedAssignmentId) {
91523
+ const where = {
91524
+ ...buildAssignmentResultWhere({
91525
+ studentId,
91526
+ assignmentId: stampedAssignmentId,
91527
+ resultType: "assessment"
91528
+ }),
91529
+ scoreStatus: SCORE_STATUS2.fullyGraded
91530
+ };
91531
+ const page = await client.api.oneroster.assessmentResults.list({
91532
+ limit: 1,
91533
+ where,
91534
+ sort: "scoreDate",
91535
+ orderBy: "desc"
91536
+ });
91537
+ parent = page.data[0] ?? null;
91538
+ }
91539
+ const summary = parent ? toTriggeringResult(parent) : null;
91540
+ let trigger = "not_stamped";
91541
+ if (stampedAssignmentId) {
91542
+ trigger = summary ? "found" : "not_found";
91543
+ }
91544
+ setAttributes({
91545
+ "app.timeback.hole_filling_trigger": trigger,
91546
+ "app.timeback.hole_filling_assignment_id": stampedAssignmentId
91547
+ });
91548
+ if (!parent || !summary) {
91549
+ return {
91550
+ enrollment: enrollmentContext,
91551
+ triggeringResult: null,
91552
+ missedStandards: [],
91553
+ untaggedMissedQuestionNumbers: []
91554
+ };
91555
+ }
91556
+ const cacheKey = `${enrollment.courseId}:${studentId}:${summary.resultId}`;
91557
+ const cached = this.holeFillingContextCache.get(cacheKey);
91558
+ if (cached && Date.now() - cached.at < TimebackService.HOLE_FILLING_CONTEXT_TTL_MS) {
91559
+ return { ...cached.context, enrollment: enrollmentContext };
91560
+ }
91561
+ const { context, complete } = await this.resolveMissedStandards(studentId, parent, summary, enrollmentContext);
91562
+ setAttribute("app.timeback.hole_filling_standards_complete", complete);
91563
+ if (complete) {
91564
+ setBounded(this.holeFillingContextCache, cacheKey, { at: Date.now(), context }, TimebackService.HOLE_FILLING_CONTEXT_CACHE_SIZE);
91565
+ }
91566
+ return context;
91567
+ }
91568
+ async resolveMissedStandards(studentId, parent, summary, enrollmentContext) {
91569
+ const client = this.requireClient();
91570
+ let complete = true;
91571
+ let missed = missedQuestionsFromInlineResponses(parent);
91572
+ if (!missed) {
91573
+ const where = buildAssignmentResultWhere({
91574
+ studentId,
91575
+ assignmentId: summary.assignmentId,
91576
+ resultType: "question"
91577
+ });
91578
+ const questionResults = await client.api.oneroster.assessmentResults.listAll({
91579
+ limit: 200,
91580
+ where,
91581
+ sort: "scoreDate",
91582
+ orderBy: "desc"
91583
+ });
91584
+ missed = missedQuestionsFromQuestionResults(questionResults);
91585
+ }
91586
+ let qtiTestId = "";
91587
+ let qtiQuestions = [];
91588
+ const standardsByItemId = new Map;
91589
+ if (missed.length > 0 && !client.hasQti) {
91590
+ complete = false;
91591
+ missed = missed.map(({ questionNumber }) => ({ questionNumber }));
91592
+ } else if (missed.some((question) => !question.qtiItemId)) {
91593
+ const lineItem = await client.api.oneroster.assessmentLineItems.get(summary.lineItemId).catch(() => null);
91594
+ qtiTestId = lineItem ? await resolveQtiTestId(client, resolveGradeLevelTestName(parent, lineItem), lineItem) : "";
91595
+ complete &&= Boolean(qtiTestId);
91596
+ if (qtiTestId) {
91597
+ qtiQuestions = await client.qtiApi.assessmentTests.getQuestions(qtiTestId).then((response) => response.questions ?? []).catch(() => {
91598
+ complete = false;
91599
+ return [];
91600
+ });
91601
+ for (const question of qtiQuestions) {
91602
+ const id = question.question.identifier || question.reference.identifier;
91603
+ if (id) {
91604
+ standardsByItemId.set(id, standardsFromQtiItem(question.question));
91605
+ }
91606
+ }
91331
91607
  }
91332
- const enrollments = await this.fetchEnrollments(userData.timebackId);
91333
- return gameId ? filterEnrollmentsByGame(enrollments, gameId) : enrollments;
91608
+ }
91609
+ const itemIdsToFetch = [
91610
+ ...new Set(missed.flatMap((question) => question.qtiItemId && !standardsByItemId.has(question.qtiItemId) ? [question.qtiItemId] : []))
91611
+ ];
91612
+ const items = await Promise.all(itemIdsToFetch.map((id) => client.qtiApi.assessmentItems.get(id).then((item) => [id, standardsFromQtiItem(item)]).catch(() => {
91613
+ complete = false;
91614
+ return [id, []];
91615
+ })));
91616
+ for (const [id, standards] of items) {
91617
+ standardsByItemId.set(id, standards);
91618
+ }
91619
+ const { missedStandards, untaggedQuestionNumbers } = groupMissedStandards(missed, standardsByItemId, qtiItemIdForQuestionNumber(qtiQuestions));
91620
+ setAttributes({
91621
+ "app.timeback.hole_filling_missed_question_count": missed.length,
91622
+ "app.timeback.hole_filling_missed_standard_count": missedStandards.length,
91623
+ "app.timeback.hole_filling_untagged_question_count": untaggedQuestionNumbers.length
91334
91624
  });
91625
+ return {
91626
+ context: {
91627
+ enrollment: enrollmentContext,
91628
+ triggeringResult: { ...summary, ...qtiTestId && { qtiTestId } },
91629
+ missedStandards,
91630
+ untaggedMissedQuestionNumbers: untaggedQuestionNumbers
91631
+ },
91632
+ complete
91633
+ };
91335
91634
  }
91336
91635
  async getUserDataByTimebackId(timebackId) {
91337
91636
  return this.withClientTelemetry(async () => {
@@ -94891,33 +95190,33 @@ var init_auth_provider = __esm(async () => {
94891
95190
  function createSandboxCacheProvider() {
94892
95191
  return {
94893
95192
  async get(key) {
94894
- const entry = cache.get(key);
95193
+ const entry = cache2.get(key);
94895
95194
  if (!entry) {
94896
95195
  return null;
94897
95196
  }
94898
95197
  if (entry.expiresAt < Date.now()) {
94899
- cache.delete(key);
95198
+ cache2.delete(key);
94900
95199
  return null;
94901
95200
  }
94902
95201
  return entry.value;
94903
95202
  },
94904
95203
  async set(key, value, ttlSeconds = 300) {
94905
- cache.set(key, {
95204
+ cache2.set(key, {
94906
95205
  value,
94907
95206
  expiresAt: Date.now() + ttlSeconds * 1000
94908
95207
  });
94909
95208
  },
94910
95209
  async delete(key) {
94911
- cache.delete(key);
95210
+ cache2.delete(key);
94912
95211
  }
94913
95212
  };
94914
95213
  }
94915
95214
  function clearSandboxCache() {
94916
- cache.clear();
95215
+ cache2.clear();
94917
95216
  }
94918
- var cache;
95217
+ var cache2;
94919
95218
  var init_cache_provider = __esm(() => {
94920
- cache = new Map;
95219
+ cache2 = new Map;
94921
95220
  });
94922
95221
 
94923
95222
  // ../utils/src/keys.ts
@@ -151145,7 +151444,7 @@ function parseQtiLibraryParams(searchParams) {
151145
151444
  limit
151146
151445
  };
151147
151446
  }
151148
- 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;
151447
+ 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;
151149
151448
  var init_timeback_controller = __esm(() => {
151150
151449
  init_esm();
151151
151450
  init_src();
@@ -151171,6 +151470,12 @@ var init_timeback_controller = __esm(() => {
151171
151470
  });
151172
151471
  getUser = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserData(ctx.user.id, ctx.gameId));
151173
151472
  getUserEnrollments = requireNonAnonymous(async (ctx) => ctx.services.timeback.getUserEnrollments(ctx.user.id, ctx.gameId));
151473
+ getUserHoleFillingContext = requireNonAnonymous(async (ctx) => {
151474
+ if (!ctx.gameId) {
151475
+ throw ApiError.forbidden("Hole-filling context requires a game token");
151476
+ }
151477
+ return ctx.services.timeback.getHoleFillingContext(ctx.user.id, ctx.gameId);
151478
+ });
151174
151479
  getUserById = requireNonAnonymous(async (ctx) => {
151175
151480
  const timebackId = ctx.params.timebackId;
151176
151481
  if (!timebackId) {
@@ -152024,6 +152329,7 @@ var init_timeback_controller = __esm(() => {
152024
152329
  populateStudent,
152025
152330
  getUser,
152026
152331
  getUserEnrollments,
152332
+ getUserHoleFillingContext,
152027
152333
  getUserById,
152028
152334
  setupIntegration,
152029
152335
  getIntegrations,
@@ -153086,6 +153392,12 @@ var init_timeback8 = __esm(async () => {
153086
153392
  }
153087
153393
  return handle2(timeback2.getUserEnrollments)(c);
153088
153394
  });
153395
+ timebackRouter.get("/user/hole-filling-context", async (c) => {
153396
+ if (shouldMockTimeback()) {
153397
+ return c.json({ contexts: [] });
153398
+ }
153399
+ return handle2(timeback2.getUserHoleFillingContext)(c);
153400
+ });
153089
153401
  timebackRouter.get("/user/:timebackId", async (c) => {
153090
153402
  const user = c.get("user");
153091
153403
  if (!user) {