feat: Implement SM2

This commit is contained in:
Alexander Polynomdivision 2018-09-30 16:17:54 +02:00
parent 64bcc932d7
commit 048f375724
8 changed files with 151 additions and 21 deletions

View File

@ -18,7 +18,9 @@
lastLevel: number, lastLevel: number,
levels: number[], levels: number[],
queue: number[], vocabMetadata: {
[id: number]: ISM2Metadata,
},
} }
` `

View File

@ -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();

View File

@ -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,
}, },
}); });

View File

@ -0,0 +1,5 @@
export interface ISM2Metadata {
easiness: number;
consecutiveCorrectAnswers: number;
nextDueDate: number;
};

View File

@ -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;
};
}; };
}; };

View File

@ -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

View File

@ -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");

View File

@ -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() {