feat: phase 4 of AI pipeline hardening — quiz & content quality
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m31s

- src/lib/random.js: Fisher–Yates shuffle/sample/pickInt; replace every
  biased .sort(() => 0.5 - Math.random()) site in testService.
- testService: debias correctIndex via prompt + runtime re-roll (up to 2x
  when one position holds >50%); quality gate rejecting <4 distinct
  options, banned filler ("all of the above" etc) and explanations
  shorter than 20 chars; dedup new questions against the existing bank
  via normalised question text.
- Quiz schema/tool/prompt require difficulty ('easy'|'medium'|'hard');
  db.getQuizBank defaults legacy records to 'medium' on read.
- learningService.generateCustomTopic: kebab-case slug ID from the
  polished label with collision suffixes; default learning_relevance
  'standard' when the model omits it.
- Tests for random helpers, dedup/quality-gate behaviour and the
  extended quiz schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-20 19:22:10 +02:00
parent c82e4fc3a1
commit 66e0c275da
9 changed files with 407 additions and 74 deletions

29
src/lib/random.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* Shared randomness helpers.
*
* `Array.prototype.sort(() => 0.5 - Math.random())` is biased — modern V8
* sorts use Timsort, which compares each element more than once and skews
* the resulting permutation. Use `shuffle` for anything user-visible
* (quiz options, review topic selection, leaderboards).
*/
export function shuffle(arr) {
const out = [...arr];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export function sample(arr, n) {
if (n <= 0) return [];
if (n >= arr.length) return shuffle(arr);
return shuffle(arr).slice(0, n);
}
export function pickInt(min, maxInclusive) {
const lo = Math.ceil(min);
const hi = Math.floor(maxInclusive);
return lo + Math.floor(Math.random() * (hi - lo + 1));
}