@vue-skuilder/db 0.1.34 → 0.1.36

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.
package/dist/index.mjs CHANGED
@@ -854,6 +854,7 @@ var PipelineDebugger_exports = {};
854
854
  __export(PipelineDebugger_exports, {
855
855
  buildRunReport: () => buildRunReport,
856
856
  captureRun: () => captureRun,
857
+ clearRunHistory: () => clearRunHistory,
857
858
  mountPipelineDebugger: () => mountPipelineDebugger,
858
859
  pipelineDebugAPI: () => pipelineDebugAPI,
859
860
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -861,6 +862,9 @@ __export(PipelineDebugger_exports, {
861
862
  function registerPipelineForDebug(pipeline) {
862
863
  _activePipeline = pipeline;
863
864
  }
865
+ function clearRunHistory() {
866
+ runHistory.length = 0;
867
+ }
864
868
  function getOrigin(card) {
865
869
  const firstEntry = card.provenance[0];
866
870
  if (!firstEntry) return "unknown";
@@ -889,7 +893,7 @@ function parseCardElo(provenance) {
889
893
  }
890
894
  function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo, hints) {
891
895
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
892
- const cards = allCards.map((card) => ({
896
+ const toReport = (card) => ({
893
897
  cardId: card.cardId,
894
898
  courseId: card.courseId,
895
899
  origin: getOrigin(card),
@@ -899,7 +903,47 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
899
903
  provenance: card.provenance,
900
904
  tags: card.tags,
901
905
  selected: selectedIds.has(card.cardId)
902
- }));
906
+ });
907
+ const selectedReported = [];
908
+ const nearMissReported = [];
909
+ const discardedTailCards = [];
910
+ let nonSelectedSeen = 0;
911
+ for (const card of allCards) {
912
+ if (selectedIds.has(card.cardId)) {
913
+ selectedReported.push(toReport(card));
914
+ } else if (nonSelectedSeen < DISCARDED_KEEP_TOP) {
915
+ nearMissReported.push(toReport(card));
916
+ nonSelectedSeen++;
917
+ } else {
918
+ discardedTailCards.push(card);
919
+ }
920
+ }
921
+ const cards = [...selectedReported, ...nearMissReported];
922
+ let discardedTail;
923
+ if (discardedTailCards.length > 0) {
924
+ let scoreMin = Infinity;
925
+ let scoreMax = -Infinity;
926
+ let eloMin = Infinity;
927
+ let eloMax = -Infinity;
928
+ let eloSeen = false;
929
+ for (const c of discardedTailCards) {
930
+ if (c.score < scoreMin) scoreMin = c.score;
931
+ if (c.score > scoreMax) scoreMax = c.score;
932
+ const elo = parseCardElo(c.provenance);
933
+ if (elo !== void 0) {
934
+ eloSeen = true;
935
+ if (elo < eloMin) eloMin = elo;
936
+ if (elo > eloMax) eloMax = elo;
937
+ }
938
+ }
939
+ const eloFragment = eloSeen ? `, ELO ${eloMin}\u2013${eloMax}` : "";
940
+ discardedTail = {
941
+ count: discardedTailCards.length,
942
+ scoreRange: [scoreMin, scoreMax],
943
+ eloRange: eloSeen ? [eloMin, eloMax] : void 0,
944
+ note: `${discardedTailCards.length} additional candidate(s) scored below the top ${DISCARDED_KEEP_TOP} near-misses and were not retained (score ${scoreMin.toExponential(2)}\u2013${scoreMax.toExponential(2)}${eloFragment}). Likely ELO-window pull remnants filtered out by hierarchy/lesson/priority gates.`
945
+ };
946
+ }
903
947
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
904
948
  const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
905
949
  return {
@@ -914,7 +958,8 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
914
958
  finalCount: selectedCards.length,
915
959
  reviewsSelected,
916
960
  newSelected,
917
- cards
961
+ cards,
962
+ discardedTail
918
963
  };
919
964
  }
920
965
  function formatProvenance(provenance) {
@@ -950,6 +995,44 @@ function printRunSummary(run) {
950
995
  );
951
996
  console.groupEnd();
952
997
  }
998
+ function escapeHtml(s) {
999
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1000
+ }
1001
+ function escapeAttr(s) {
1002
+ return escapeHtml(s).replace(/"/g, "&quot;");
1003
+ }
1004
+ function copyTextToClipboard(text, btn) {
1005
+ const done = () => {
1006
+ if (!btn) return;
1007
+ const orig = btn.textContent ?? "Copy";
1008
+ btn.textContent = "Copied!";
1009
+ btn.classList.add("copied");
1010
+ setTimeout(() => {
1011
+ btn.textContent = orig;
1012
+ btn.classList.remove("copied");
1013
+ }, 1200);
1014
+ };
1015
+ const fallback = () => {
1016
+ const ta = document.createElement("textarea");
1017
+ ta.value = text;
1018
+ ta.style.position = "fixed";
1019
+ ta.style.opacity = "0";
1020
+ document.body.appendChild(ta);
1021
+ ta.select();
1022
+ try {
1023
+ document.execCommand("copy");
1024
+ } catch (e) {
1025
+ logger.warn(`[Pipeline Debug] Copy failed: ${e}`);
1026
+ }
1027
+ document.body.removeChild(ta);
1028
+ done();
1029
+ };
1030
+ if (navigator.clipboard?.writeText) {
1031
+ navigator.clipboard.writeText(text).then(done).catch(fallback);
1032
+ } else {
1033
+ fallback();
1034
+ }
1035
+ }
953
1036
  function renderUI() {
954
1037
  if (!_uiContainer) return;
955
1038
  const runs = runHistory;
@@ -1008,6 +1091,13 @@ function renderUI() {
1008
1091
  #sk-pipeline-debugger .close-btn { background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
1009
1092
  #sk-pipeline-debugger .search-box { margin-bottom: 1rem; width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; }
1010
1093
  #sk-pipeline-debugger .provenance { font-size: 12px; color: #666; margin-top: 0.25rem; white-space: pre-wrap; font-family: monospace; background: #f8f9fa; padding: 0.5rem; border-radius: 4px; }
1094
+ #sk-pipeline-debugger .run-label { display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.4rem; background: #fff3cd; color: #664d03; border-radius: 3px; font-family: monospace; font-size: 11px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
1095
+ #sk-pipeline-debugger .label-banner { display: inline-block; padding: 0.25rem 0.6rem; background: #fff3cd; color: #664d03; border-radius: 4px; font-family: monospace; font-size: 13px; margin: 0 0 0.75rem 0; }
1096
+ #sk-pipeline-debugger .copy-btn { background: #0d6efd; color: white; border: none; padding: 0.25rem 0.6rem; border-radius: 3px; cursor: pointer; font-size: 12px; margin-left: 0.5rem; }
1097
+ #sk-pipeline-debugger .copy-btn:hover { background: #0b5ed7; }
1098
+ #sk-pipeline-debugger .copy-btn.copied { background: #198754; }
1099
+ #sk-pipeline-debugger .section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; }
1100
+ #sk-pipeline-debugger .section-head h3 { margin: 0; }
1011
1101
  `;
1012
1102
  const runListHtml = runs.length === 0 ? '<div style="padding: 1rem;">No runs captured yet.</div>' : runs.map(
1013
1103
  (r, i) => `
@@ -1015,6 +1105,7 @@ function renderUI() {
1015
1105
  <strong>${r.timestamp.toLocaleTimeString()}</strong><br/>
1016
1106
  <small>${r.courseName || r.courseId.slice(0, 8)}</small><br/>
1017
1107
  <small>${r.finalCount} cards selected</small>
1108
+ ${r.hints?._label ? `<br/><span class="run-label" title="${escapeAttr(r.hints._label)}">${escapeHtml(r.hints._label)}</span>` : ""}
1018
1109
  </div>
1019
1110
  `
1020
1111
  ).join("");
@@ -1023,11 +1114,13 @@ function renderUI() {
1023
1114
  const filteredCards = selectedRun.cards.filter(
1024
1115
  (c) => !_cardSearchQuery || c.cardId.toLowerCase().includes(_cardSearchQuery.toLowerCase())
1025
1116
  );
1117
+ const labelText = selectedRun.hints?._label ?? "(no label)";
1026
1118
  detailsHtml = `
1027
1119
  <h2>Run: ${selectedRun.runId}</h2>
1120
+ <div class="label-banner" title="${escapeAttr(labelText)}">${escapeHtml(labelText)}</div>
1028
1121
  <p>
1029
- <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
1030
- <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
1122
+ <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
1123
+ <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
1031
1124
  <strong>User ELO:</strong> ${selectedRun.userElo ?? "unknown"}
1032
1125
  </p>
1033
1126
 
@@ -1042,7 +1135,10 @@ function renderUI() {
1042
1135
  </table>
1043
1136
 
1044
1137
  ${selectedRun.hints ? `
1045
- <h3>Ephemeral Hints</h3>
1138
+ <div class="section-head">
1139
+ <h3>Ephemeral Hints</h3>
1140
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyConfig('${selectedRun.runId}', this)">Copy config</button>
1141
+ </div>
1046
1142
  <table>
1047
1143
  ${selectedRun.hints._label ? `<tr><th>Label</th><td>${selectedRun.hints._label}</td></tr>` : ""}
1048
1144
  ${selectedRun.hints.boostTags ? `<tr><th>Boost Tags</th><td><pre style="margin:0">${JSON.stringify(selectedRun.hints.boostTags, null, 2)}</pre></td></tr>` : ""}
@@ -1066,7 +1162,10 @@ function renderUI() {
1066
1162
  </tbody>
1067
1163
  </table>
1068
1164
 
1069
- <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
1165
+ <div class="section-head">
1166
+ <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
1167
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyResults('${selectedRun.runId}', this)">Copy results</button>
1168
+ </div>
1070
1169
  <input type="text" class="search-box" placeholder="Search Card ID..." value="${_cardSearchQuery}" oninput="window.skuilder.pipeline._setSearch(this.value)">
1071
1170
 
1072
1171
  <table>
@@ -1112,7 +1211,7 @@ function mountPipelineDebugger() {
1112
1211
  win.skuilder = win.skuilder || {};
1113
1212
  win.skuilder.pipeline = pipelineDebugAPI;
1114
1213
  }
1115
- var _activePipeline, MAX_RUNS, runHistory, _uiContainer, _selectedRunIndex, _cardSearchQuery, pipelineDebugAPI;
1214
+ var _activePipeline, MAX_RUNS, runHistory, DISCARDED_KEEP_TOP, _uiContainer, _selectedRunIndex, _cardSearchQuery, pipelineDebugAPI;
1116
1215
  var init_PipelineDebugger = __esm({
1117
1216
  "src/core/navigators/PipelineDebugger.ts"() {
1118
1217
  "use strict";
@@ -1121,6 +1220,7 @@ var init_PipelineDebugger = __esm({
1121
1220
  _activePipeline = null;
1122
1221
  MAX_RUNS = 10;
1123
1222
  runHistory = [];
1223
+ DISCARDED_KEEP_TOP = 25;
1124
1224
  _uiContainer = null;
1125
1225
  _selectedRunIndex = null;
1126
1226
  _cardSearchQuery = "";
@@ -1185,7 +1285,14 @@ var init_PipelineDebugger = __esm({
1185
1285
  return;
1186
1286
  }
1187
1287
  }
1188
- logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1288
+ const runsWithTails = runHistory.filter((r) => r.discardedTail && r.discardedTail.count > 0);
1289
+ if (runsWithTails.length > 0) {
1290
+ logger.info(
1291
+ `[Pipeline Debug] Card '${cardId}' not found in retained cards. ${runsWithTails.length} run(s) have discarded tails that were not retained \u2014 the card may have been a low-score candidate. See run.discardedTail for ranges.`
1292
+ );
1293
+ } else {
1294
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1295
+ }
1189
1296
  },
1190
1297
  /**
1191
1298
  * Explain why reviews may or may not have been selected.
@@ -1490,6 +1597,50 @@ var init_PipelineDebugger = __esm({
1490
1597
  _cardSearchQuery = query;
1491
1598
  renderUI();
1492
1599
  },
1600
+ /**
1601
+ * Internal UI helpers
1602
+ * @internal
1603
+ */
1604
+ _copyConfig(runId, btn) {
1605
+ const run = runHistory.find((r) => r.runId === runId);
1606
+ if (!run) return;
1607
+ const payload = {
1608
+ runId: run.runId,
1609
+ timestamp: run.timestamp.toISOString(),
1610
+ courseId: run.courseId,
1611
+ courseName: run.courseName,
1612
+ hints: run.hints ?? null
1613
+ };
1614
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1615
+ },
1616
+ /**
1617
+ * Internal UI helpers
1618
+ * @internal
1619
+ *
1620
+ * Copies an "abridged" view of results: just the selected cards with their
1621
+ * generator, origin, final score, and the top provenance reason. Designed
1622
+ * for pasting into bug reports without flooding with full provenance.
1623
+ */
1624
+ _copyResults(runId, btn) {
1625
+ const run = runHistory.find((r) => r.runId === runId);
1626
+ if (!run) return;
1627
+ const selected = run.cards.filter((c) => c.selected).sort((a, b) => b.finalScore - a.finalScore).map((c) => ({
1628
+ cardId: c.cardId,
1629
+ generator: c.generator,
1630
+ origin: c.origin,
1631
+ score: Number(c.finalScore.toFixed(3)),
1632
+ topReason: c.provenance[0]?.reason ?? ""
1633
+ }));
1634
+ const payload = {
1635
+ runId: run.runId,
1636
+ label: run.hints?._label ?? null,
1637
+ finalCount: run.finalCount,
1638
+ newSelected: run.newSelected,
1639
+ reviewsSelected: run.reviewsSelected,
1640
+ selected
1641
+ };
1642
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1643
+ },
1493
1644
  /**
1494
1645
  * Show help.
1495
1646
  */
@@ -5804,12 +5955,12 @@ ${e.stack}` : JSON.stringify(e);
5804
5955
  async getWeightedCards(limit) {
5805
5956
  const u = await this._getCurrentUser();
5806
5957
  try {
5807
- const navigator = await this.createNavigator(u);
5958
+ const navigator2 = await this.createNavigator(u);
5808
5959
  if (this._pendingHints) {
5809
- navigator.setEphemeralHints(this._pendingHints);
5960
+ navigator2.setEphemeralHints(this._pendingHints);
5810
5961
  this._pendingHints = null;
5811
5962
  }
5812
- return navigator.getWeightedCards(limit);
5963
+ return navigator2.getWeightedCards(limit);
5813
5964
  } catch (e) {
5814
5965
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
5815
5966
  throw e;
@@ -8853,12 +9004,12 @@ var init_courseDB2 = __esm({
8853
9004
  }
8854
9005
  async getWeightedCards(limit) {
8855
9006
  try {
8856
- const navigator = await this.createNavigator(this.userDB);
9007
+ const navigator2 = await this.createNavigator(this.userDB);
8857
9008
  if (this._pendingHints) {
8858
- navigator.setEphemeralHints(this._pendingHints);
9009
+ navigator2.setEphemeralHints(this._pendingHints);
8859
9010
  this._pendingHints = null;
8860
9011
  }
8861
- return navigator.getWeightedCards(limit);
9012
+ return navigator2.getWeightedCards(limit);
8862
9013
  } catch (e) {
8863
9014
  logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
8864
9015
  throw e;
@@ -12810,10 +12961,12 @@ mountMixerDebugger();
12810
12961
 
12811
12962
  // src/study/SessionDebugger.ts
12812
12963
  init_logger();
12964
+ init_PipelineDebugger();
12813
12965
  var activeSession = null;
12814
12966
  var sessionHistory = [];
12815
12967
  var MAX_HISTORY = 5;
12816
12968
  function startSessionTracking(reviewQLength, newQLength, failedQLength) {
12969
+ clearRunHistory();
12817
12970
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12818
12971
  activeSession = {
12819
12972
  sessionId,
@@ -13271,8 +13424,19 @@ var SessionController = class _SessionController extends Loggable {
13271
13424
  /**
13272
13425
  * Request a mid-session replan. Re-runs the pipeline with current user state
13273
13426
  * and atomically replaces the newQ contents. Safe to call at any time during
13274
- * a session — if called while a replan is already in progress, returns the
13275
- * existing replan promise (no duplicate work).
13427
+ * a session.
13428
+ *
13429
+ * Concurrency policy:
13430
+ * - Two unhinted auto-replans never run in parallel; the second coalesces
13431
+ * into the first (returns the same promise).
13432
+ * - A hint-bearing replan that arrives while another replan is in flight
13433
+ * is queued to run **after** the in-flight one rather than dropped.
13434
+ * This preserves caller intent (label, requireCards, excludeTags,
13435
+ * limit, minFollowUpCards) instead of silently discarding it. Without
13436
+ * queueing, a background auto-replan that started just before a
13437
+ * completion-triggered replan would clobber the queue with unhinted
13438
+ * results (e.g. surfacing another gpc-intro card right after one
13439
+ * completed, skipping the prescribed `c-wst-*` follow-up).
13276
13440
  *
13277
13441
  * Does NOT affect reviewQ or failedQ.
13278
13442
  *
@@ -13288,10 +13452,55 @@ var SessionController = class _SessionController extends Loggable {
13288
13452
  if (opts.hints || opts.label || opts.limit) {
13289
13453
  this._depletionReplanAttempted = false;
13290
13454
  }
13455
+ const hasIntent = this._replanHasIntent(opts);
13291
13456
  if (this._replanPromise) {
13292
- this.log("Replan already in progress, awaiting existing replan");
13293
- return this._replanPromise;
13457
+ if (!hasIntent) {
13458
+ this.log("Replan already in progress, coalescing unhinted auto-replan");
13459
+ return this._replanPromise;
13460
+ }
13461
+ const labelTag = opts.label ? ` [${opts.label}]` : "";
13462
+ this.log(
13463
+ `Replan in progress; queueing hint-bearing replan${labelTag} behind in-flight run`
13464
+ );
13465
+ const inflight = this._replanPromise;
13466
+ const queued = inflight.catch(() => void 0).then(() => this._runReplan(opts));
13467
+ this._replanPromise = queued.finally(() => {
13468
+ if (this._replanPromise === queued) this._replanPromise = null;
13469
+ });
13470
+ return queued;
13294
13471
  }
13472
+ const run = this._runReplan(opts);
13473
+ this._replanPromise = run.finally(() => {
13474
+ if (this._replanPromise === run) this._replanPromise = null;
13475
+ });
13476
+ await run;
13477
+ }
13478
+ /**
13479
+ * True when a requestReplan call carries caller intent that must not be
13480
+ * silently dropped. Bare unhinted auto-replans (depletion / quality
13481
+ * triggers in nextCard) return false and may coalesce.
13482
+ */
13483
+ _replanHasIntent(opts) {
13484
+ if (opts.label) return true;
13485
+ if (opts.limit !== void 0) return true;
13486
+ if (opts.minFollowUpCards !== void 0) return true;
13487
+ if (opts.mode && opts.mode !== "replace") return true;
13488
+ if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13489
+ return false;
13490
+ }
13491
+ /**
13492
+ * Body of a single replan: populate auto-excludes, stash hints on
13493
+ * sources, log, then run the pipeline. Extracted so it can be invoked
13494
+ * either immediately (no in-flight replan) or queued (chained after
13495
+ * the in-flight one resolves).
13496
+ *
13497
+ * IMPORTANT: hint stash and the queue-state snapshot used to build
13498
+ * excludeCards happen at *invocation* time, not at *queue* time. For a
13499
+ * queued replan that means excludes reflect the state after the prior
13500
+ * replan landed — which is what we want, since the prior replan's
13501
+ * newQ.peek(0) is the imminent draw we need to exclude.
13502
+ */
13503
+ async _runReplan(opts) {
13295
13504
  if (!opts.hints) opts.hints = {};
13296
13505
  const hints = opts.hints;
13297
13506
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -13301,6 +13510,9 @@ var SessionController = class _SessionController extends Loggable {
13301
13510
  for (const rec of this._sessionRecord) {
13302
13511
  excludeSet.add(rec.card.card_id);
13303
13512
  }
13513
+ if (this.newQ.length > 0) {
13514
+ excludeSet.add(this.newQ.peek(0).cardID);
13515
+ }
13304
13516
  hints.excludeCards = [...excludeSet];
13305
13517
  if (opts.hints) {
13306
13518
  const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
@@ -13316,12 +13528,7 @@ var SessionController = class _SessionController extends Loggable {
13316
13528
  this._minCardsGuarantee = Math.max(this._minCardsGuarantee, opts.minFollowUpCards);
13317
13529
  this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
13318
13530
  }
13319
- this._replanPromise = this._executeReplan(opts);
13320
- try {
13321
- await this._replanPromise;
13322
- } finally {
13323
- this._replanPromise = null;
13324
- }
13531
+ await this._executeReplan(opts);
13325
13532
  }
13326
13533
  /**
13327
13534
  * Normalise the requestReplan argument. Accepts either a ReplanOptions