Skip to content

Commit f98c0bc

Browse files
kokonut121claude
andauthored
fix repeating questions and add various analytics algos (#608)
* various algos * fix repeat questions in Classic Trainer by tracking session-seen questions Track answered question IDs in req.session.seenQuestions per subject, exclude them from getQuestions() via $nin, and widen the rating ceiling by 200 (up to 5x) when no unseen questions exist in the current range. Silently drop queued questions already seen without rating penalty. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c24c5f0 commit f98c0bc

4 files changed

Lines changed: 292 additions & 41 deletions

File tree

routes/train.js

Lines changed: 86 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ module.exports = (app, mongo) => {
8686
// clear pending question
8787
clearQuestionQueue(req, antsy.subject[0]);
8888

89+
// mark as seen for the current session (prevents in-session repeats)
90+
const _seenSubjectKey = antsy.subject[0].toLowerCase();
91+
if (!req.session.seenQuestions) req.session.seenQuestions = {};
92+
if (!req.session.seenQuestions[_seenSubjectKey]) req.session.seenQuestions[_seenSubjectKey] = [];
93+
const _seenIdStr = antsy._id.toString();
94+
if (!req.session.seenQuestions[_seenSubjectKey].includes(_seenIdStr)) {
95+
req.session.seenQuestions[_seenSubjectKey].push(_seenIdStr);
96+
}
97+
8998
// check answer
9099
if (antsy.answer[0] == req.body.answerChoice) {
91100
isRight = true;
@@ -147,6 +156,15 @@ module.exports = (app, mongo) => {
147156
// clear pending question
148157
clearQuestionQueue(req, antsy.subject[0]);
149158

159+
// mark as seen for the current session (prevents in-session repeats)
160+
const _seenSubjectKey = antsy.subject[0].toLowerCase();
161+
if (!req.session.seenQuestions) req.session.seenQuestions = {};
162+
if (!req.session.seenQuestions[_seenSubjectKey]) req.session.seenQuestions[_seenSubjectKey] = [];
163+
const _seenIdStr = antsy._id.toString();
164+
if (!req.session.seenQuestions[_seenSubjectKey].includes(_seenIdStr)) {
165+
req.session.seenQuestions[_seenSubjectKey].push(_seenIdStr);
166+
}
167+
150168
// check answer
151169
isRight = arraysEqual(antsy.answer, req.body.saChoice);
152170

@@ -206,6 +224,15 @@ module.exports = (app, mongo) => {
206224
// clear pending question
207225
clearQuestionQueue(req, antsy.subject[0]);
208226

227+
// mark as seen for the current session (prevents in-session repeats)
228+
const _seenSubjectKey = antsy.subject[0].toLowerCase();
229+
if (!req.session.seenQuestions) req.session.seenQuestions = {};
230+
if (!req.session.seenQuestions[_seenSubjectKey]) req.session.seenQuestions[_seenSubjectKey] = [];
231+
const _seenIdStr = antsy._id.toString();
232+
if (!req.session.seenQuestions[_seenSubjectKey].includes(_seenIdStr)) {
233+
req.session.seenQuestions[_seenSubjectKey].push(_seenIdStr);
234+
}
235+
209236
// check answer
210237
for (let j = 0; j < antsy.answer.length; j++) {
211238
if (
@@ -490,13 +517,23 @@ module.exports = (app, mongo) => {
490517
res.setHeader('Expires', '0');
491518
// define units and attempt to get queued question
492519
const units = req.query.units.split(',');
520+
const subjectKey = req.params.subject.toLowerCase();
521+
522+
// session-scoped seen-question tracker (prevents in-session repeats)
523+
if (!req.session.seenQuestions) req.session.seenQuestions = {};
524+
if (!req.session.seenQuestions[subjectKey]) req.session.seenQuestions[subjectKey] = [];
525+
const seenIds = req.session.seenQuestions[subjectKey];
526+
493527
let q = '';
494528

495-
if (req.user.stats.toAnswer[req.params.subject.toLowerCase()]) {
496-
q = await getQuestion(
497-
mongo.Ques,
498-
req.user.stats.toAnswer[req.params.subject.toLowerCase()]
499-
);
529+
if (req.user.stats.toAnswer[subjectKey]) {
530+
const queuedId = req.user.stats.toAnswer[subjectKey];
531+
// drop the queued question silently (no rating penalty) if it was already answered this session
532+
if (seenIds.includes(queuedId.toString())) {
533+
clearQuestionQueue(req, subjectKey);
534+
} else {
535+
q = await getQuestion(mongo.Ques, queuedId);
536+
}
500537
}
501538

502539
// get experience stats
@@ -522,7 +559,7 @@ module.exports = (app, mongo) => {
522559
skipQuestionUpdates(
523560
mongo.Ques,
524561
req,
525-
req.params.subject.toLowerCase(),
562+
subjectKey,
526563
q._id
527564
);
528565
}
@@ -533,44 +570,55 @@ module.exports = (app, mongo) => {
533570
}
534571

535572
let ceilingFloor = ratingCeilingFloor(
536-
req.user.rating[req.params.subject.toLowerCase()]
573+
req.user.rating[subjectKey]
537574
);
538575
const floor = ceilingFloor.floor;
539-
const ceiling = ceilingFloor.ceiling;
576+
let ceiling = ceilingFloor.ceiling;
540577

541578
//debugging usage
542579
console.log(floor);
543580
console.log(ceiling);
544-
// get question
545-
getQuestions(mongo.Ques, floor, ceiling, req.params.subject, units).then(
546-
(qs) => {
547-
//console.log(qs);
548-
549-
// select random question
550-
curQ = qs[Math.floor(Math.random() * qs.length)];
551-
console.log(curQ);
552-
if (!curQ) {
553-
req.flash(
554-
'errorFlash',
555-
"We couldn't find any questions for your rating in the units you selected."
556-
);
557-
res.redirect('/train/' + req.params.subject + '/chooseUnits');
558-
return;
559-
}
560-
// update pending question field
561-
updateQuestionQueue(req, req.params.subject, curQ._id);
562-
// push to frontend
563-
res.render(VIEWS + 'private/train/displayQuestion.ejs', {
564-
units: units,
565-
newQues: curQ,
566-
subject: req.params.subject,
567-
user: req.user,
568-
experienceStats,
569-
pageName: 'Classic Trainer',
570-
referenceSheet,
571-
});
572-
}
573-
);
581+
582+
// retry-and-widen: push ceiling up (floor stays) until unseen questions appear
583+
const STEP = 200;
584+
const MAX_ITERATIONS = 5;
585+
let qs = [];
586+
for (let i = 0; i < MAX_ITERATIONS; i++) {
587+
qs = await getQuestions(mongo.Ques, floor, ceiling, req.params.subject, units, seenIds);
588+
if (qs.length > 0) break;
589+
ceiling += STEP;
590+
console.log('no unseen questions; expanding ceiling to', ceiling);
591+
}
592+
593+
// fallback: user has exhausted the unseen pool — allow a repeat rather than erroring
594+
if (qs.length === 0) {
595+
console.log('seen-filtered pool exhausted, falling back to full pool');
596+
qs = await getQuestions(mongo.Ques, floor, ceiling, req.params.subject, units);
597+
}
598+
599+
// select random question
600+
curQ = qs[Math.floor(Math.random() * qs.length)];
601+
console.log(curQ);
602+
if (!curQ) {
603+
req.flash(
604+
'errorFlash',
605+
"We couldn't find any questions for your rating in the units you selected."
606+
);
607+
res.redirect('/train/' + req.params.subject + '/chooseUnits');
608+
return;
609+
}
610+
// update pending question field
611+
updateQuestionQueue(req, req.params.subject, curQ._id);
612+
// push to frontend
613+
res.render(VIEWS + 'private/train/displayQuestion.ejs', {
614+
units: units,
615+
newQues: curQ,
616+
subject: req.params.subject,
617+
user: req.user,
618+
experienceStats,
619+
pageName: 'Classic Trainer',
620+
referenceSheet,
621+
});
574622
}
575623
});
576624

utils/functions/database.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ function getQuestion(Ques, id) {
1212
}
1313

1414
// input a rating range (as floor and ceiling values), returns a range of questions
15-
async function getQuestions(Ques, ratingFloor, ratingCeiling, subject, units) {
16-
const gotQ = Ques.find({
15+
async function getQuestions(Ques, ratingFloor, ratingCeiling, subject, units, excludeIds = []) {
16+
const query = {
1717
subject: [subject],
1818
rating: { $gte: ratingFloor, $lte: ratingCeiling },
19-
});
19+
};
20+
if (excludeIds && excludeIds.length > 0) {
21+
query._id = { $nin: excludeIds.map((id) => mongoose.Types.ObjectId(id)) };
22+
}
23+
const gotQ = Ques.find(query);
2024

2125
let tempQ = await gotQ.exec();
2226

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
const { Ques, db } = require('../mongo');
2+
3+
/**
4+
* Usage:
5+
* node utils/functions/scripts.js 0-1000 1000-1500 1500-2000
6+
*
7+
* Each argument should be a range in the form "lower-upper" (or "lower:upper"),
8+
* where bounds are numeric ratings.
9+
*
10+
* If no ranges are provided, a default set is used.
11+
*/
12+
13+
function parseRanges(args) {
14+
if (!args.length) {
15+
return [
16+
{ label: '0-1000', min: 0, max: 1000 },
17+
{ label: '1000-1500', min: 1000, max: 1500 },
18+
{ label: '1500-2000', min: 1500, max: 2000 },
19+
{ label: '2000-2500', min: 2000, max: 2500 },
20+
{ label: '2500-3000', min: 2500, max: 3000 },
21+
];
22+
}
23+
24+
return args
25+
.map((arg) => {
26+
const cleaned = String(arg).trim();
27+
if (!cleaned) return null;
28+
29+
// support "a-b" or "a:b"
30+
const parts = cleaned.includes('-')
31+
? cleaned.split('-')
32+
: cleaned.split(':');
33+
34+
if (parts.length !== 2) return null;
35+
36+
const min = Number(parts[0]);
37+
const max = Number(parts[1]);
38+
39+
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
40+
41+
return {
42+
label: `${min}-${max}`,
43+
min,
44+
max,
45+
};
46+
})
47+
.filter(Boolean)
48+
.sort((a, b) => a.min - b.min);
49+
}
50+
51+
async function countQuestionsByRatingRanges(ranges) {
52+
const results = ranges.map((r) => ({ ...r, count: 0 }));
53+
54+
const questions = await Ques.find({}, { rating: 1 }).lean().exec();
55+
56+
for (const q of questions) {
57+
const rating = Number(q.rating);
58+
if (!Number.isFinite(rating)) continue;
59+
60+
for (const r of results) {
61+
// lower bound inclusive, upper bound exclusive
62+
if (rating >= r.min && rating < r.max) {
63+
r.count += 1;
64+
break;
65+
}
66+
}
67+
}
68+
69+
return { total: questions.length, results };
70+
}
71+
72+
async function main() {
73+
try {
74+
const args = process.argv.slice(2);
75+
const ranges = parseRanges(args);
76+
77+
if (!ranges.length) {
78+
console.error('No valid rating ranges provided.');
79+
process.exitCode = 1;
80+
return;
81+
}
82+
83+
const { total, results } = await countQuestionsByRatingRanges(ranges);
84+
85+
console.log(`Total questions: ${total}`);
86+
for (const r of results) {
87+
console.log(`${r.label}: ${r.count}`);
88+
}
89+
} catch (err) {
90+
console.error('Error counting questions by rating range:', err);
91+
process.exitCode = 1;
92+
} finally {
93+
// Close the Mongo connection cleanly
94+
if (db && db.close) {
95+
db.close(() => {
96+
process.exit();
97+
});
98+
} else {
99+
process.exit();
100+
}
101+
}
102+
}
103+
104+
if (require.main === module) {
105+
main();
106+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const { PendingQues, Ques, db } = require('../mongo');
2+
3+
/**
4+
* Transfers pending questions that already have exactly one reviewer
5+
* from the `pendingQuestions` collection to the `questions` collection.
6+
*
7+
* Usage:
8+
* node utils/functions/scripts/transferReviewedQuestions.js
9+
*/
10+
11+
async function transferReviewedQuestions() {
12+
// Find pending questions that have exactly one reviewer
13+
const pending = await PendingQues.find(
14+
{ reviewers: { $size: 1 } },
15+
{
16+
question: 1,
17+
choices: 1,
18+
tags: 1,
19+
rating: 1,
20+
answer: 1,
21+
answer_ex: 1,
22+
author: 1,
23+
type: 1,
24+
ext_source: 1,
25+
source_statement: 1,
26+
subject: 1,
27+
units: 1,
28+
reviewers: 1,
29+
writtenDate: 1,
30+
hourRefactor: 1,
31+
}
32+
)
33+
.lean()
34+
.exec();
35+
36+
if (!pending.length) {
37+
console.log('No pending questions with exactly one reviewer found.');
38+
return { transferred: 0 };
39+
}
40+
41+
const idsToRemove = [];
42+
43+
const questionsToInsert = pending.map((doc) => {
44+
idsToRemove.push(doc._id);
45+
46+
const { _id, ...rest } = doc;
47+
48+
return {
49+
...rest,
50+
// Initialize stats for newly accepted questions
51+
stats: {
52+
pass: 0,
53+
fail: 0,
54+
},
55+
};
56+
});
57+
58+
const insertResult = await Ques.insertMany(questionsToInsert);
59+
60+
const deleteResult = await PendingQues.deleteMany({
61+
_id: { $in: idsToRemove },
62+
});
63+
64+
return {
65+
transferred: insertResult.length || 0,
66+
deletedFromPending: deleteResult.deletedCount || 0,
67+
};
68+
}
69+
70+
async function main() {
71+
try {
72+
const { transferred, deletedFromPending } =
73+
await transferReviewedQuestions();
74+
75+
console.log(`Transferred to questions: ${transferred}`);
76+
console.log(`Removed from pendingQuestions: ${deletedFromPending}`);
77+
} catch (err) {
78+
console.error('Error transferring reviewed questions:', err);
79+
process.exitCode = 1;
80+
} finally {
81+
if (db && db.close) {
82+
db.close(() => {
83+
process.exit();
84+
});
85+
} else {
86+
process.exit();
87+
}
88+
}
89+
}
90+
91+
if (require.main === module) {
92+
main();
93+
}

0 commit comments

Comments
 (0)