@vue-skuilder/db 0.2.4 → 0.2.5

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
@@ -1917,12 +1917,13 @@ __export(elo_exports, {
1917
1917
  default: () => ELONavigator
1918
1918
  });
1919
1919
  import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1920
- var ELONavigator;
1920
+ var ELO_RELEVANCE_SIGMA, ELONavigator;
1921
1921
  var init_elo = __esm({
1922
1922
  "src/core/navigators/generators/elo.ts"() {
1923
1923
  "use strict";
1924
1924
  init_navigators();
1925
1925
  init_logger();
1926
+ ELO_RELEVANCE_SIGMA = 300;
1926
1927
  ELONavigator = class extends ContentNavigator {
1927
1928
  /** Human-readable name for CardGenerator interface */
1928
1929
  name;
@@ -1962,8 +1963,8 @@ var init_elo = __esm({
1962
1963
  const scored = newCards.map((c) => {
1963
1964
  const cardElo = c.elo ?? 1e3;
1964
1965
  const distance = Math.abs(cardElo - userGlobalElo);
1965
- const rawScore = Math.max(0, 1 - distance / 500);
1966
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
1966
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
1967
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1967
1968
  return {
1968
1969
  cardId: c.cardID,
1969
1970
  courseId: c.courseID,
@@ -1975,7 +1976,7 @@ var init_elo = __esm({
1975
1976
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1976
1977
  action: "generated",
1977
1978
  score: samplingKey,
1978
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1979
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1979
1980
  }
1980
1981
  ]
1981
1982
  };
@@ -13071,7 +13072,15 @@ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834"
13071
13072
  var spinnerFrame = 0;
13072
13073
  var overlayEl = null;
13073
13074
  var pollHandle = null;
13074
- var expanded = { reviewQ: false, newQ: false, failedQ: false };
13075
+ var lastSnapshot = null;
13076
+ var copyFlashUntil = 0;
13077
+ var minified = false;
13078
+ var expanded = {
13079
+ reviewQ: false,
13080
+ newQ: false,
13081
+ failedQ: false,
13082
+ drawn: false
13083
+ };
13075
13084
  function toggleSessionOverlay() {
13076
13085
  if (typeof document === "undefined") {
13077
13086
  logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
@@ -13086,6 +13095,7 @@ function toggleSessionOverlay() {
13086
13095
  }
13087
13096
  }
13088
13097
  function mount() {
13098
+ minified = false;
13089
13099
  overlayEl = document.createElement("div");
13090
13100
  overlayEl.id = OVERLAY_ID;
13091
13101
  Object.assign(overlayEl.style, {
@@ -13124,11 +13134,23 @@ function render() {
13124
13134
  spinnerFrame++;
13125
13135
  const ctrl = getActiveController();
13126
13136
  if (!ctrl) {
13127
- overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13137
+ lastSnapshot = null;
13138
+ overlayEl.innerHTML = headerHtml() + (minified ? "" : `<div style="opacity:.65">No active session.</div>`);
13139
+ attachHandlers();
13128
13140
  return;
13129
13141
  }
13130
13142
  const s = ctrl.getDebugSnapshot();
13131
- overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ);
13143
+ lastSnapshot = s;
13144
+ if (minified) {
13145
+ overlayEl.innerHTML = headerHtml();
13146
+ attachHandlers();
13147
+ return;
13148
+ }
13149
+ overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13150
+ attachHandlers();
13151
+ }
13152
+ function attachHandlers() {
13153
+ if (!overlayEl) return;
13132
13154
  overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13133
13155
  el.onclick = () => {
13134
13156
  const key = el.dataset.q;
@@ -13137,9 +13159,45 @@ function render() {
13137
13159
  render();
13138
13160
  };
13139
13161
  });
13162
+ const copyBtn = overlayEl.querySelector("[data-copy]");
13163
+ if (copyBtn) {
13164
+ copyBtn.onclick = (ev) => {
13165
+ ev.stopPropagation();
13166
+ copySnapshot();
13167
+ };
13168
+ }
13169
+ const minBtn = overlayEl.querySelector("[data-min]");
13170
+ if (minBtn) {
13171
+ minBtn.onclick = (ev) => {
13172
+ ev.stopPropagation();
13173
+ minified = !minified;
13174
+ render();
13175
+ };
13176
+ }
13177
+ }
13178
+ function copySnapshot() {
13179
+ const text = snapshotToText(lastSnapshot);
13180
+ const flash = () => {
13181
+ copyFlashUntil = Date.now() + 1200;
13182
+ render();
13183
+ };
13184
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
13185
+ if (clip?.writeText) {
13186
+ clip.writeText(text).then(flash, (err) => {
13187
+ logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
13188
+ });
13189
+ } else {
13190
+ logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:
13191
+ ${text}`);
13192
+ }
13140
13193
  }
13141
13194
  function headerHtml() {
13142
- return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
13195
+ const flashing = Date.now() < copyFlashUntil;
13196
+ const btnLabel = flashing ? "\u2713 copied" : "\u2398 copy";
13197
+ const btnColor = flashing ? "#86efac" : "#93c5fd";
13198
+ const copyBtn = `<span data-copy style="cursor:pointer;float:right;font-weight:400;color:${btnColor};border:1px solid currentColor;border-radius:4px;padding:0 4px;line-height:1.3">${btnLabel}</span>`;
13199
+ const minBtn = `<span data-min title="${minified ? "Restore" : "Minify"}" style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;border:1px solid currentColor;border-radius:4px;padding:0 5px;margin-right:4px;line-height:1.3">${minified ? "\u25A2" : "\u2014"}</span>`;
13200
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">${copyBtn}${minBtn}\u2699 SessionController</div>`;
13143
13201
  }
13144
13202
  function replanHtml(s) {
13145
13203
  if (!s.replanActive) {
@@ -13182,22 +13240,98 @@ function hintsHtml(h) {
13182
13240
  }
13183
13241
  function queueHtml(key, label, q) {
13184
13242
  const collapsible = q.length > INLINE_THRESHOLD;
13185
- const isOpen = !collapsible || expanded[key];
13243
+ const isOpen = collapsible && expanded[key];
13186
13244
  const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13187
13245
  const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13188
13246
  const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13189
13247
  const titleAttr = collapsible ? ` data-q="${key}"` : "";
13190
13248
  const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13191
- let body = "";
13192
- if (isOpen && q.cards.length) {
13193
- body = `<ol style="margin:2px 0 6px 0;padding-left:20px">` + q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13194
- } else if (!q.cards.length) {
13195
- body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13196
- } else {
13197
- body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards \u2014 click to expand)</div>`;
13249
+ if (!q.cards.length) {
13250
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13251
+ }
13252
+ const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
13253
+ const hiddenCount = q.length - shown.length;
13254
+ const listMarginBottom = collapsible ? 2 : 6;
13255
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13256
+ if (collapsible) {
13257
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13258
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13259
+ }
13260
+ return title + body;
13261
+ }
13262
+ function outcomeGlyph(correct) {
13263
+ if (correct === true) return `<span style="color:#86efac">\u2713</span>`;
13264
+ if (correct === false) return `<span style="color:#fca5a5">\u2717</span>`;
13265
+ return `<span style="opacity:.5">\xB7</span>`;
13266
+ }
13267
+ function drawnHtml(key, drawn) {
13268
+ const collapsible = drawn.length > INLINE_THRESHOLD;
13269
+ const isOpen = collapsible && expanded[key];
13270
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13271
+ const titleStyle = collapsible ? "cursor:pointer;color:#c4b5fd" : "color:#c4b5fd";
13272
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13273
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
13274
+ if (!drawn.length) {
13275
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
13276
+ }
13277
+ const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
13278
+ const hiddenCount = drawn.length - shown.length;
13279
+ const listMarginBottom = collapsible ? 2 : 6;
13280
+ const rows = shown.map((d) => {
13281
+ const retries = d.attempts > 1 ? `<span style="opacity:.5"> \xD7${d.attempts}</span>` : "";
13282
+ const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
13283
+ return `<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`;
13284
+ }).join("");
13285
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
13286
+ if (collapsible) {
13287
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13288
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13198
13289
  }
13199
13290
  return title + body;
13200
13291
  }
13292
+ function snapshotToText(s) {
13293
+ if (!s) return "SessionController \u2014 no active session.";
13294
+ const lines = [];
13295
+ lines.push("=== SessionController ===");
13296
+ lines.push(`time ${formatTime(s.secondsRemaining)}`);
13297
+ if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
13298
+ lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
13299
+ lines.push(`current: ${s.currentCard ?? "\u2014"}`);
13300
+ lines.push(
13301
+ s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? "(auto)"}]` : "replan: idle"
13302
+ );
13303
+ lines.push("");
13304
+ lines.push("sessionHints:");
13305
+ const h = s.sessionHints;
13306
+ const hintParts = [];
13307
+ if (h) {
13308
+ if (h.boostTags && Object.keys(h.boostTags).length)
13309
+ hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13310
+ if (h.boostCards && Object.keys(h.boostCards).length)
13311
+ hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13312
+ if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(", ")}`);
13313
+ if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(", ")}`);
13314
+ if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(", ")}`);
13315
+ if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
13316
+ }
13317
+ lines.push(hintParts.length ? hintParts.join("\n") : " none");
13318
+ const queueText = (label, q) => {
13319
+ lines.push("");
13320
+ lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
13321
+ q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
13322
+ };
13323
+ queueText("reviewQ", s.reviewQ);
13324
+ queueText("newQ", s.newQ);
13325
+ queueText("failedQ", s.failedQ);
13326
+ lines.push("");
13327
+ lines.push(`drawn: ${s.drawnCards.length}`);
13328
+ s.drawnCards.forEach((d, i) => {
13329
+ const mark = d.correct === true ? "\u2713" : d.correct === false ? "\u2717" : "\xB7";
13330
+ const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
13331
+ lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] \xD7${d.attempts} ${time}`);
13332
+ });
13333
+ return lines.join("\n");
13334
+ }
13201
13335
  function formatTime(totalSeconds) {
13202
13336
  const s = Math.max(0, Math.round(totalSeconds));
13203
13337
  const m = Math.floor(s / 60);
@@ -13567,6 +13701,21 @@ var SessionController = class _SessionController extends Loggable {
13567
13701
  * recomputed per-run in `_runReplan` and would otherwise go stale.
13568
13702
  */
13569
13703
  _sessionHints = null;
13704
+ /**
13705
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
13706
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
13707
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
13708
+ * lands once the card is *responded to*.
13709
+ *
13710
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
13711
+ * card shown once must never re-enter newQ this session. This is the general
13712
+ * guard against re-presentation — including the case where a replan in flight
13713
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
13714
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
13715
+ * legitimately recur and are tracked by their own queues, so this only gates
13716
+ * `new`-origin candidates.
13717
+ */
13718
+ _servedCardIds = /* @__PURE__ */ new Set();
13570
13719
  /**
13571
13720
  * Consumer-supplied hooks invoked after each question response is processed.
13572
13721
  * Seeded from constructor options (threaded from
@@ -13776,6 +13925,7 @@ var SessionController = class _SessionController extends Loggable {
13776
13925
  if (opts.mode && opts.mode !== "replace") return true;
13777
13926
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13778
13927
  if (opts.sessionHints !== void 0) return true;
13928
+ if (opts.mergeSessionHints !== void 0) return true;
13779
13929
  return false;
13780
13930
  }
13781
13931
  /**
@@ -13811,6 +13961,10 @@ var SessionController = class _SessionController extends Loggable {
13811
13961
  `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13812
13962
  );
13813
13963
  }
13964
+ if (opts.mergeSessionHints !== void 0) {
13965
+ this._sessionHints = mergeHints2([this._sessionHints, opts.mergeSessionHints]) ?? null;
13966
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
13967
+ }
13814
13968
  this._applyHintsToSources(opts.hints, opts.label);
13815
13969
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13816
13970
  this.log(
@@ -13863,6 +14017,16 @@ var SessionController = class _SessionController extends Loggable {
13863
14017
  }
13864
14018
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
13865
14019
  };
14020
+ const drawnCards = this._sessionRecord.map((r) => {
14021
+ const last = r.records[r.records.length - 1];
14022
+ return {
14023
+ cardID: r.item.cardID,
14024
+ status: r.item.status,
14025
+ attempts: r.records.length,
14026
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
14027
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0)
14028
+ };
14029
+ });
13866
14030
  return {
13867
14031
  secondsRemaining: this.secondsRemaining,
13868
14032
  hasCardGuarantee: this.hasCardGuarantee,
@@ -13874,7 +14038,8 @@ var SessionController = class _SessionController extends Loggable {
13874
14038
  replanLabel: this._activeReplanLabel,
13875
14039
  reviewQ: describe(this.reviewQ),
13876
14040
  newQ: describe(this.newQ),
13877
- failedQ: describe(this.failedQ)
14041
+ failedQ: describe(this.failedQ),
14042
+ drawnCards
13878
14043
  };
13879
14044
  }
13880
14045
  /**
@@ -14183,7 +14348,7 @@ var SessionController = class _SessionController extends Loggable {
14183
14348
  mixedWeighted
14184
14349
  );
14185
14350
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14186
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new").slice(0, newLimit);
14351
+ const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)).slice(0, newLimit);
14187
14352
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14188
14353
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14189
14354
  if (!replan) {
@@ -14320,7 +14485,7 @@ var SessionController = class _SessionController extends Loggable {
14320
14485
  this.log(
14321
14486
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14322
14487
  );
14323
- void this.requestReplan({ label: "auto:depletion" });
14488
+ void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14324
14489
  }
14325
14490
  const REPLAN_BUFFER = 3;
14326
14491
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
@@ -14461,6 +14626,8 @@ var SessionController = class _SessionController extends Loggable {
14461
14626
  * Remove an item from its source queue after consumption by nextCard().
14462
14627
  */
14463
14628
  removeItemFromQueue(item) {
14629
+ this._clearDurableRequirement(item.cardID);
14630
+ this._servedCardIds.add(item.cardID);
14464
14631
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14465
14632
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14466
14633
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
@@ -14472,6 +14639,27 @@ var SessionController = class _SessionController extends Loggable {
14472
14639
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
14473
14640
  }
14474
14641
  }
14642
+ /**
14643
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
14644
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
14645
+ * the card was not a durable requirement.
14646
+ *
14647
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
14648
+ * several cards) is NOT considered satisfied by a single draw and is left in
14649
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
14650
+ * remain the right tool for them.
14651
+ */
14652
+ _clearDurableRequirement(cardID) {
14653
+ const req = this._sessionHints?.requireCards;
14654
+ if (!req || req.length === 0) return;
14655
+ const next = req.filter((id) => id !== cardID);
14656
+ if (next.length === req.length) return;
14657
+ this._sessionHints = {
14658
+ ...this._sessionHints,
14659
+ requireCards: next.length > 0 ? next : void 0
14660
+ };
14661
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
14662
+ }
14475
14663
  /**
14476
14664
  * End the session and record learning outcomes.
14477
14665
  *