feat: Implement SM2
This commit is contained in:
parent
64bcc932d7
commit
048f375724
@ -18,7 +18,9 @@
|
|||||||
lastLevel: number,
|
lastLevel: number,
|
||||||
levels: number[],
|
levels: number[],
|
||||||
|
|
||||||
queue: number[],
|
vocabMetadata: {
|
||||||
|
[id: number]: ISM2Metadata,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -32,12 +32,19 @@ levelRouter.get("/:id/vocab", async (req: LRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the level
|
// Find the level
|
||||||
// TODO: if (level)
|
|
||||||
const { db } = req;
|
const { db } = req;
|
||||||
const level = await db.collection("levels").findOne({
|
const level = await db.collection("levels").findOne({
|
||||||
// TODO: This aint safe, boi
|
|
||||||
level: levelId,
|
level: levelId,
|
||||||
});
|
});
|
||||||
|
if (!level) {
|
||||||
|
res.send({
|
||||||
|
error: "404",
|
||||||
|
data: {
|
||||||
|
msg: `Cannot find level with id "${levelId}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all the vocabulary
|
// Fetch all the vocabulary
|
||||||
const vocab = await db.collection("vocabulary").find({ id: { $in: level.vocab } }).toArray();
|
const vocab = await db.collection("vocabulary").find({ id: { $in: level.vocab } }).toArray();
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import * as express from "express";
|
import * as express from "express";
|
||||||
import * as bodyparser from "body-parser";
|
import * as bodyparser from "body-parser";
|
||||||
|
|
||||||
|
import { Db } from "mongodb";
|
||||||
|
|
||||||
import { authRoute } from "../security/token";
|
import { authRoute } from "../security/token";
|
||||||
|
|
||||||
import { userFromSession } from "../utils/user";
|
import { userFromSession } from "../utils/user";
|
||||||
|
|
||||||
import { IUser, IUserDBModel } from "../models/user";
|
import { IUser, IUserDBModel } from "../models/user";
|
||||||
|
import { ISM2Metadata } from "../models/review";
|
||||||
import { LRequest } from "../types/express";
|
import { LRequest } from "../types/express";
|
||||||
import { Db } from "mongodb";
|
|
||||||
|
|
||||||
const userRouter = express.Router();
|
const userRouter = express.Router();
|
||||||
userRouter.use(authRoute);
|
userRouter.use(authRoute);
|
||||||
@ -65,13 +67,30 @@ export enum VocabType {
|
|||||||
userRouter.get("/queue", async (req: LRequest, res) => {
|
userRouter.get("/queue", async (req: LRequest, res) => {
|
||||||
// TODO: if (user)
|
// TODO: if (user)
|
||||||
const { token, db } = req;
|
const { token, db } = req;
|
||||||
const user = await userFromSession(token, db);
|
let user = Object.assign({}, await userFromSession(token, db));
|
||||||
|
|
||||||
|
const sm2 = user.vocabMetadata;
|
||||||
|
const data = Object.keys(sm2).map(id => Object.assign({}, sm2[parseInt(id)], { id, }));
|
||||||
|
const sorted = data.sort((a: any, b: any) => {
|
||||||
|
if (a.nextDueDate > b.nextDueDate)
|
||||||
|
return 1;
|
||||||
|
if (a.nextDueDate < b.nextDueDate)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}).slice(0, 20)
|
||||||
|
.map((el: any) => {
|
||||||
|
return parseInt(el.id);
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch all vocab ids from the vocabulary collection
|
// Fetch all vocab ids from the vocabulary collection
|
||||||
const vocabRaw = await db.collection("vocabulary").find({
|
const vocabRaw = await db.collection("vocabulary").find({
|
||||||
id: { $in: user.queue },
|
id: { $in: sorted },
|
||||||
}, {
|
}, {
|
||||||
// TODO: Make this configurable?
|
// TODO: Make this configurable?
|
||||||
|
sort: {
|
||||||
|
|
||||||
|
},
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}).toArray();
|
}).toArray();
|
||||||
|
|
||||||
@ -102,18 +121,69 @@ userRouter.get("/lastReview", async (req: LRequest, res) => {
|
|||||||
data: user.lastReview,
|
data: user.lastReview,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function dateInNDays(n: number): number {
|
||||||
|
let today = new Date();
|
||||||
|
today.setDate(today.getDate() + n);
|
||||||
|
return Date.parse(today.toString());
|
||||||
|
}
|
||||||
|
|
||||||
userRouter.post("/lastReview", async (req: LRequest, res) => {
|
userRouter.post("/lastReview", async (req: LRequest, res) => {
|
||||||
// TODO: Check if we get the correct data
|
// Check if we get the needed data
|
||||||
// TODO: if(user)
|
if (!req.body || !("meta" in req.body) || !("sm2" in req.body)) {
|
||||||
|
res.send({
|
||||||
|
error: "400",
|
||||||
|
data: {
|
||||||
|
msg: "Last review state or SM2 data not specified!",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { token, db } = req;
|
const { token, db } = req;
|
||||||
const user = await userFromSession(token, db);
|
let user = Object.assign({}, await userFromSession(token, db));
|
||||||
|
if (!user) {
|
||||||
|
res.send({
|
||||||
|
error: "404",
|
||||||
|
data: {
|
||||||
|
msg: "Failed to find user with the specified session!",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(req.body.sm2).forEach((id: string) => {
|
||||||
|
const vocabId = parseInt(id);
|
||||||
|
const correct: boolean = req.body.sm2[id];
|
||||||
|
let vocab_sm2: ISM2Metadata = Object.assign({},
|
||||||
|
user.vocabMetadata[vocabId]);
|
||||||
|
|
||||||
|
// TODO: Tuning?
|
||||||
|
// TODO: Move into another module
|
||||||
|
const perf = correct ? 3 : 1;
|
||||||
|
vocab_sm2.easiness += -0.8 + 0.28 * perf + 0.02 * Math.pow(perf, 2);
|
||||||
|
// Update the consecutive correct answers and the due date
|
||||||
|
if (correct) {
|
||||||
|
vocab_sm2.consecutiveCorrectAnswers += 1;
|
||||||
|
vocab_sm2.nextDueDate = dateInNDays(6 * Math.pow(vocab_sm2.easiness, vocab_sm2.consecutiveCorrectAnswers - 1));
|
||||||
|
} else {
|
||||||
|
vocab_sm2.consecutiveCorrectAnswers = 0;
|
||||||
|
vocab_sm2.nextDueDate = dateInNDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.vocabMetadata[vocabId] = vocab_sm2;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the last review
|
||||||
|
user.lastReview = req.body.meta;
|
||||||
|
|
||||||
// TODO: Error handling
|
// TODO: Error handling
|
||||||
await db.collection("users").updateOne({
|
await db.collection("users").updateOne({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
}, {
|
}, {
|
||||||
$set: {
|
$set: {
|
||||||
lastReview: req.body.meta,
|
lastReview: user.lastReview,
|
||||||
|
vocabMetadata: user.vocabMetadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
5
backend/src/models/review.ts
Normal file
5
backend/src/models/review.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface ISM2Metadata {
|
||||||
|
easiness: number;
|
||||||
|
consecutiveCorrectAnswers: number;
|
||||||
|
nextDueDate: number;
|
||||||
|
};
|
@ -1,3 +1,5 @@
|
|||||||
|
import { ISM2Metadata } from "./review";
|
||||||
|
|
||||||
export interface IUserDBModel {
|
export interface IUserDBModel {
|
||||||
username: string;
|
username: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
@ -20,12 +22,8 @@ export interface IUserDBModel {
|
|||||||
queue: number[];
|
queue: number[];
|
||||||
|
|
||||||
// Vocabulary ID -> SM2 Metadata
|
// Vocabulary ID -> SM2 Metadata
|
||||||
metadata: {
|
vocabMetadata: {
|
||||||
[key: number]: {
|
[key: number]: ISM2Metadata;
|
||||||
easiness: number;
|
|
||||||
nextDueDate: number;
|
|
||||||
consecutiveCorrectAnswers: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,3 +4,5 @@ rm -rf dist/
|
|||||||
./node_modules/.bin/parcel build --out-dir dist/app src/index.html
|
./node_modules/.bin/parcel build --out-dir dist/app src/index.html
|
||||||
chmod 705 dist dist/app
|
chmod 705 dist dist/app
|
||||||
chmod 604 dist/app/*
|
chmod 604 dist/app/*
|
||||||
|
|
||||||
|
#sed "s/\/src/\/app\/src/" dist/app/index.html
|
||||||
|
@ -106,7 +106,8 @@ export default class Application extends React.Component<IProps> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastReview = (meta: IReviewMetadata) => {
|
// TODO: Type?
|
||||||
|
setLastReview = (meta: IReviewMetadata, sm2: any) => {
|
||||||
// Update the state
|
// Update the state
|
||||||
this.props.setLastReview(meta);
|
this.props.setLastReview(meta);
|
||||||
|
|
||||||
@ -119,6 +120,7 @@ export default class Application extends React.Component<IProps> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
meta,
|
meta,
|
||||||
|
sm2,
|
||||||
}),
|
}),
|
||||||
}).then(resp => resp.json(), err => {
|
}).then(resp => resp.json(), err => {
|
||||||
console.log("Application::setLastReview: POSTing last results failed");
|
console.log("Application::setLastReview: POSTing last results failed");
|
||||||
|
@ -33,6 +33,7 @@ interface IProps {
|
|||||||
levelId?: number;
|
levelId?: number;
|
||||||
vocabByLevel?: (level: number) => Promise<IVocab[]>;
|
vocabByLevel?: (level: number) => Promise<IVocab[]>;
|
||||||
vocabByQueue?: () => Promise<IVocab[]>;
|
vocabByQueue?: () => Promise<IVocab[]>;
|
||||||
|
setLastReview: (meta: IReviewMetadata, sm2: any) => void;
|
||||||
reviewType: ReviewType;
|
reviewType: ReviewType;
|
||||||
|
|
||||||
history: any;
|
history: any;
|
||||||
@ -51,7 +52,6 @@ interface IProps {
|
|||||||
setSummary: (state: boolean) => void;
|
setSummary: (state: boolean) => void;
|
||||||
setPopover: (state: boolean, text: string, color: string, textColor: string) => void;
|
setPopover: (state: boolean, text: string, color: string, textColor: string) => void;
|
||||||
drawerButtonState: (state: boolean) => void;
|
drawerButtonState: (state: boolean) => void;
|
||||||
setLastReview: (meta: IReviewMetadata) => void;
|
|
||||||
setReview: (curent: IReviewCard, meta: IReviewMetadata) => void;
|
setReview: (curent: IReviewCard, meta: IReviewMetadata) => void;
|
||||||
setLoading: (state: boolean) => void;
|
setLoading: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
@ -63,6 +63,7 @@ const ReviewPageWithRouter = withRouter(
|
|||||||
// Used for positioning the popover
|
// Used for positioning the popover
|
||||||
private buttonRef: HTMLButtonElement;
|
private buttonRef: HTMLButtonElement;
|
||||||
private inputRef: HTMLInputElement;
|
private inputRef: HTMLInputElement;
|
||||||
|
// Mapping: Vocab Id -> Correctly answered
|
||||||
private sm2_metadata: any = {};
|
private sm2_metadata: any = {};
|
||||||
|
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
@ -133,6 +134,46 @@ const ReviewPageWithRouter = withRouter(
|
|||||||
return this.vocab.find((el) => el.id === this.props.current.id);
|
return this.vocab.find((el) => el.id === this.props.current.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a vocabulary item has been answered, we need to update
|
||||||
|
// the group's SuperMemo2-data.
|
||||||
|
// We update based on the following State-Machine: (C = Correct; W = Wrong)
|
||||||
|
// Answer | Group | New group
|
||||||
|
// | | state
|
||||||
|
// -------+-------+----------
|
||||||
|
// C | C | C
|
||||||
|
// C | W | W
|
||||||
|
// W | C | W
|
||||||
|
// W | W | W
|
||||||
|
// (1)C | - | C
|
||||||
|
// (2)W | - | W
|
||||||
|
// @correct: Was the answer given correct?
|
||||||
|
updateGroupSM2 = (correct: boolean) => {
|
||||||
|
const { id } = this.props.current;
|
||||||
|
switch (correct) {
|
||||||
|
case true:
|
||||||
|
// Case (1)
|
||||||
|
if (!(id in this.sm2_metadata)) {
|
||||||
|
this.sm2_metadata[id] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.sm2_metadata[id]) {
|
||||||
|
case true:
|
||||||
|
this.sm2_metadata[id] = true;
|
||||||
|
break;
|
||||||
|
case false:
|
||||||
|
this.sm2_metadata[id] = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case false:
|
||||||
|
// We don't need to explicitly catch case (2), as we set
|
||||||
|
// anything, that was incorrectly answered to false.
|
||||||
|
this.sm2_metadata[id] = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
checkInput = () => {
|
checkInput = () => {
|
||||||
// Check if the given answer is somewhere in the german words
|
// Check if the given answer is somewhere in the german words
|
||||||
const input = this.inputRef.value || "";
|
const input = this.inputRef.value || "";
|
||||||
@ -146,12 +187,14 @@ const ReviewPageWithRouter = withRouter(
|
|||||||
|
|
||||||
// Check if the user's answer was correct
|
// Check if the user's answer was correct
|
||||||
if (minDist === 0) {
|
if (minDist === 0) {
|
||||||
|
this.updateGroupSM2(true);
|
||||||
|
|
||||||
// TODO: Show it's correct?
|
// TODO: Show it's correct?
|
||||||
// Show the next vocab word
|
// Show the next vocab word
|
||||||
if (this.reviewQueue.size() === 0) {
|
if (this.reviewQueue.size() === 0) {
|
||||||
// Update the metadata
|
// Update the metadata
|
||||||
this.props.setReview(this.props.current, this.increaseMeta(1, 0));
|
this.props.setReview(this.props.current, this.increaseMeta(1, 0));
|
||||||
this.props.setLastReview(this.props.metadata);
|
this.props.setLastReview(this.props.metadata, this.sm2_metadata);
|
||||||
this.props.setLoading(true);
|
this.props.setLoading(true);
|
||||||
|
|
||||||
// Show the drawer button again
|
// Show the drawer button again
|
||||||
@ -163,6 +206,8 @@ const ReviewPageWithRouter = withRouter(
|
|||||||
// Increase the vocab
|
// Increase the vocab
|
||||||
this.props.setReview(this.reviewQueue.dequeue(), this.increaseMeta(1, 0));
|
this.props.setReview(this.reviewQueue.dequeue(), this.increaseMeta(1, 0));
|
||||||
this.inputRef.value = "";
|
this.inputRef.value = "";
|
||||||
|
|
||||||
|
// TODO(?): Show a snackbar for showing the updated score
|
||||||
}
|
}
|
||||||
} else if (minDist <= LEVENSHTEIN_MAX_DISTANCE) {
|
} else if (minDist <= LEVENSHTEIN_MAX_DISTANCE) {
|
||||||
this.props.setPopover(true, "Das war fast richtig", "yellow", "black");
|
this.props.setPopover(true, "Das war fast richtig", "yellow", "black");
|
||||||
@ -178,11 +223,10 @@ const ReviewPageWithRouter = withRouter(
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
// Update the metadata
|
// Update the metadata
|
||||||
|
this.updateGroupSM2(false);
|
||||||
this.props.setReview(this.props.current, this.increaseMeta(0, 1));
|
this.props.setReview(this.props.current, this.increaseMeta(0, 1));
|
||||||
this.props.setPopover(true, "Das war nicht richtig", "red", "white");
|
this.props.setPopover(true, "Das war nicht richtig", "red", "white");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(?): Show a snackbar for showing the updated score
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
Reference in New Issue
Block a user