forked from wangziqi/gongxue-base
feat: support composite practice questions
This commit is contained in:
@@ -17,9 +17,11 @@ import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAcce
|
||||
interface QuestionAnswerRow {
|
||||
question_id: string;
|
||||
question_version_id: string | null;
|
||||
type: string | null;
|
||||
correct_option_index: number | null;
|
||||
correct_option_indices: unknown;
|
||||
answer_text: string | null;
|
||||
sub_questions: unknown;
|
||||
}
|
||||
|
||||
interface PracticeBlueprintRow {
|
||||
@@ -95,6 +97,7 @@ interface PracticeSessionReportAnswerRow {
|
||||
questionId: string;
|
||||
selectedOptions: unknown;
|
||||
answerText: string | null;
|
||||
answerPayload: unknown;
|
||||
isCorrect: boolean | null;
|
||||
answeredAt: string;
|
||||
}
|
||||
@@ -112,6 +115,56 @@ interface PracticeSessionReportQuestionRow {
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: unknown;
|
||||
answerText: string | null;
|
||||
subQuestions: unknown;
|
||||
}
|
||||
|
||||
interface CompositeSubAnswer {
|
||||
subQuestionId: string;
|
||||
selectedOptions: string[];
|
||||
answerText: string;
|
||||
selfJudgedCorrect?: boolean;
|
||||
}
|
||||
|
||||
interface CompositeSubQuestion {
|
||||
id: string;
|
||||
type: string;
|
||||
typeLabel: string | null;
|
||||
content: string | null;
|
||||
options: unknown[];
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: number[];
|
||||
answerText: string | null;
|
||||
explanation: string | null;
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
interface CompositeSubResult {
|
||||
subQuestionId: string;
|
||||
order: number;
|
||||
type: string;
|
||||
typeLabel: string | null;
|
||||
content: string | null;
|
||||
selectedOptions: string[];
|
||||
answerText: string | null;
|
||||
isCorrect: boolean | null;
|
||||
selfJudged: boolean;
|
||||
correctOptionIndex: number | null;
|
||||
correctOptionIndices: number[];
|
||||
correctAnswerText: string | null;
|
||||
explanation: string | null;
|
||||
answered: boolean;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
interface CompositeAnswerEvaluation {
|
||||
mode: 'composite';
|
||||
subResults: CompositeSubResult[];
|
||||
answeredCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
unansweredCount: number;
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
|
||||
interface PracticeReportQuestionResult {
|
||||
@@ -130,6 +183,7 @@ interface PracticeReportQuestionResult {
|
||||
correctAnswerText: string | null;
|
||||
explanation: string | null;
|
||||
content: string | null;
|
||||
subResults?: CompositeSubResult[];
|
||||
}
|
||||
|
||||
interface SectionDefinition {
|
||||
@@ -216,17 +270,103 @@ function arraysEqual<T>(left: T[], right: T[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
|
||||
const correctIndices = normalizeNumberArray(row.correct_option_indices);
|
||||
if (correctIndices.length) {
|
||||
function isObjectiveType(type: string | null | undefined) {
|
||||
const normalized = String(type || '').toLowerCase();
|
||||
return ['choice', 'multi', 'judge', 'image'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeSelectedOptions(value: unknown) {
|
||||
return normalizeStringArray(value)
|
||||
.map(item => String(Number(item)))
|
||||
.filter(item => item !== 'NaN');
|
||||
}
|
||||
|
||||
function subQuestionIdAt(index: number, value: Record<string, unknown>) {
|
||||
const raw = value.id ?? value.subQuestionId ?? value.key;
|
||||
return typeof raw === 'string' && raw.trim() ? raw.trim() : `sub_${index + 1}`;
|
||||
}
|
||||
|
||||
function normalizeCompositeSubQuestions(value: unknown): CompositeSubQuestion[] {
|
||||
return jsonArray(value).map((raw, index) => {
|
||||
const source = jsonObject(raw);
|
||||
const correctOptionIndices = normalizeNumberArray(source.correctOptionIndices ?? source.correct_option_indices);
|
||||
const correctOptionIndex = source.correctOptionIndex ?? source.correct_option_index;
|
||||
const numericCorrectOptionIndex = correctOptionIndex === null || correctOptionIndex === undefined
|
||||
? null
|
||||
: Number(correctOptionIndex);
|
||||
const score = source.score === undefined || source.score === null ? null : finiteNumber(source.score, 0);
|
||||
return {
|
||||
id: subQuestionIdAt(index, source),
|
||||
type: typeof source.type === 'string' && source.type.trim() ? source.type.trim() : 'choice',
|
||||
typeLabel: typeof source.typeLabel === 'string'
|
||||
? source.typeLabel
|
||||
: typeof source.type_label === 'string'
|
||||
? source.type_label
|
||||
: null,
|
||||
content: typeof source.content === 'string' ? source.content : null,
|
||||
options: jsonArray(source.options),
|
||||
correctOptionIndex: typeof numericCorrectOptionIndex === 'number' && Number.isFinite(numericCorrectOptionIndex)
|
||||
? Math.trunc(numericCorrectOptionIndex)
|
||||
: null,
|
||||
correctOptionIndices,
|
||||
answerText: typeof source.answerText === 'string'
|
||||
? source.answerText
|
||||
: typeof source.answer_text === 'string'
|
||||
? source.answer_text
|
||||
: null,
|
||||
explanation: typeof source.explanation === 'string' ? source.explanation : null,
|
||||
score: score === null ? null : round2(score),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseSubAnswers(value: unknown): CompositeSubAnswer[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new HttpError(400, 'subAnswers must be an array', 'INVALID_SUB_ANSWERS');
|
||||
}
|
||||
return value.map((raw, index) => {
|
||||
const source = jsonObject(raw);
|
||||
const subQuestionId = source.subQuestionId ?? source.id ?? source.key;
|
||||
if (typeof subQuestionId !== 'string' || !subQuestionId.trim()) {
|
||||
throw new HttpError(400, `subAnswers[${index}].subQuestionId is required`, 'SUB_ANSWER_ID_REQUIRED');
|
||||
}
|
||||
const selfJudgedCorrect = source.selfJudgedCorrect;
|
||||
if (selfJudgedCorrect !== undefined && selfJudgedCorrect !== null && typeof selfJudgedCorrect !== 'boolean') {
|
||||
throw new HttpError(400, `subAnswers[${index}].selfJudgedCorrect must be boolean`, 'INVALID_BOOLEAN_FIELD');
|
||||
}
|
||||
return {
|
||||
subQuestionId: subQuestionId.trim(),
|
||||
selectedOptions: normalizeSelectedOptions(source.selectedOptions),
|
||||
answerText: typeof source.answerText === 'string' ? source.answerText : '',
|
||||
selfJudgedCorrect: typeof selfJudgedCorrect === 'boolean' ? selfJudgedCorrect : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function judgeObjectiveAnswer(selectedOptions: string[], correctOptionIndices: number[], correctOptionIndex: number | null) {
|
||||
if (correctOptionIndices.length) {
|
||||
return arraysEqual(
|
||||
selectedOptions.map(item => Number(item)).filter(Number.isFinite).map(item => Math.trunc(item)).sort((a, b) => a - b),
|
||||
correctIndices,
|
||||
correctOptionIndices,
|
||||
);
|
||||
}
|
||||
|
||||
if (correctOptionIndex !== null && correctOptionIndex !== undefined) {
|
||||
return selectedOptions.length === 1 && Number(selectedOptions[0]) === correctOptionIndex;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerText: string) {
|
||||
const correctIndices = normalizeNumberArray(row.correct_option_indices);
|
||||
if (correctIndices.length) {
|
||||
return judgeObjectiveAnswer(selectedOptions, correctIndices, null);
|
||||
}
|
||||
|
||||
if (row.correct_option_index !== null && row.correct_option_index !== undefined) {
|
||||
return selectedOptions.length === 1 && Number(selectedOptions[0]) === row.correct_option_index;
|
||||
return judgeObjectiveAnswer(selectedOptions, [], row.correct_option_index);
|
||||
}
|
||||
|
||||
if (row.answer_text) {
|
||||
@@ -241,6 +381,187 @@ function hasObjectiveAnswer(row: QuestionAnswerRow) {
|
||||
(row.correct_option_index !== null && row.correct_option_index !== undefined);
|
||||
}
|
||||
|
||||
function hasSubObjectiveAnswer(row: CompositeSubQuestion) {
|
||||
return row.correctOptionIndices.length > 0 ||
|
||||
(row.correctOptionIndex !== null && row.correctOptionIndex !== undefined);
|
||||
}
|
||||
|
||||
function evaluateCompositeAnswer(row: QuestionAnswerRow, subAnswers: CompositeSubAnswer[]): CompositeAnswerEvaluation {
|
||||
const subQuestions = normalizeCompositeSubQuestions(row.sub_questions);
|
||||
if (!subQuestions.length) {
|
||||
throw new HttpError(400, 'Question does not contain sub questions', 'COMPOSITE_QUESTION_REQUIRED');
|
||||
}
|
||||
|
||||
const answerBySubId = new Map<string, CompositeSubAnswer>();
|
||||
for (const answer of subAnswers) {
|
||||
if (answerBySubId.has(answer.subQuestionId)) {
|
||||
throw new HttpError(400, `Duplicate sub answer ${answer.subQuestionId}`, 'DUPLICATE_SUB_ANSWER');
|
||||
}
|
||||
answerBySubId.set(answer.subQuestionId, answer);
|
||||
}
|
||||
|
||||
const allowedIds = new Set(subQuestions.map(item => item.id));
|
||||
for (const answer of subAnswers) {
|
||||
if (!allowedIds.has(answer.subQuestionId)) {
|
||||
throw new HttpError(400, `Unknown sub question ${answer.subQuestionId}`, 'UNKNOWN_SUB_ANSWER');
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackScore = subQuestions.length ? round2(1 / subQuestions.length) : 0;
|
||||
const subResults: CompositeSubResult[] = subQuestions.map((subQuestion, index) => {
|
||||
const answer = answerBySubId.get(subQuestion.id) || null;
|
||||
const selectedOptions = answer?.selectedOptions || [];
|
||||
const answerText = answer?.answerText || '';
|
||||
const objective = isObjectiveType(subQuestion.type) || hasSubObjectiveAnswer(subQuestion);
|
||||
const answered = objective ? selectedOptions.length > 0 : Boolean(answerText.trim() || answer?.selfJudgedCorrect !== undefined);
|
||||
let isCorrect: boolean | null = null;
|
||||
let selfJudged = false;
|
||||
|
||||
if (answer) {
|
||||
if (answer.selfJudgedCorrect !== undefined) {
|
||||
if (objective) {
|
||||
throw new HttpError(400, 'Self judgment is only allowed for subjective sub questions', 'SELF_JUDGMENT_NOT_ALLOWED');
|
||||
}
|
||||
isCorrect = answer.selfJudgedCorrect;
|
||||
selfJudged = true;
|
||||
} else if (objective) {
|
||||
isCorrect = judgeObjectiveAnswer(selectedOptions, subQuestion.correctOptionIndices, subQuestion.correctOptionIndex);
|
||||
} else if (subQuestion.answerText) {
|
||||
isCorrect = normalizeAnswerText(answerText) === normalizeAnswerText(subQuestion.answerText);
|
||||
}
|
||||
}
|
||||
|
||||
const totalScore = subQuestion.score !== null ? subQuestion.score : fallbackScore;
|
||||
return {
|
||||
subQuestionId: subQuestion.id,
|
||||
order: index + 1,
|
||||
type: subQuestion.type,
|
||||
typeLabel: subQuestion.typeLabel,
|
||||
content: subQuestion.content,
|
||||
selectedOptions,
|
||||
answerText: answered ? answerText : null,
|
||||
isCorrect,
|
||||
selfJudged,
|
||||
correctOptionIndex: subQuestion.correctOptionIndex,
|
||||
correctOptionIndices: subQuestion.correctOptionIndices,
|
||||
correctAnswerText: subQuestion.answerText,
|
||||
explanation: subQuestion.explanation,
|
||||
answered,
|
||||
score: isCorrect === true ? round2(totalScore) : 0,
|
||||
totalScore: round2(totalScore),
|
||||
};
|
||||
});
|
||||
|
||||
const answeredCount = subResults.filter(item => item.answered).length;
|
||||
const correctCount = subResults.filter(item => item.isCorrect === true).length;
|
||||
const wrongCount = subResults.filter(item => item.answered && item.isCorrect !== true).length;
|
||||
const unansweredCount = Math.max(0, subResults.length - answeredCount);
|
||||
if (answeredCount === 0) {
|
||||
throw new HttpError(400, 'At least one sub question must be answered', 'SUB_ANSWERS_EMPTY');
|
||||
}
|
||||
const judgedResults = subResults.filter(item => item.isCorrect !== null);
|
||||
const isCorrect = subResults.length > 0 && judgedResults.length === subResults.length
|
||||
? subResults.every(item => item.isCorrect === true)
|
||||
: answeredCount > 0
|
||||
? false
|
||||
: null;
|
||||
|
||||
return {
|
||||
mode: 'composite',
|
||||
subResults,
|
||||
answeredCount,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
unansweredCount,
|
||||
isCorrect,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoredSubResults(value: unknown): CompositeSubResult[] {
|
||||
return jsonArray(value).map((raw, index) => {
|
||||
const source = jsonObject(raw);
|
||||
const selectedOptions = normalizeStringArray(source.selectedOptions);
|
||||
const correctOptionIndex = source.correctOptionIndex;
|
||||
const numericCorrectOptionIndex = correctOptionIndex === null || correctOptionIndex === undefined
|
||||
? null
|
||||
: Number(correctOptionIndex);
|
||||
const isCorrect = typeof source.isCorrect === 'boolean' ? source.isCorrect : null;
|
||||
return {
|
||||
subQuestionId: typeof source.subQuestionId === 'string' && source.subQuestionId.trim()
|
||||
? source.subQuestionId.trim()
|
||||
: `sub_${index + 1}`,
|
||||
order: Number.isFinite(Number(source.order)) ? Math.trunc(Number(source.order)) : index + 1,
|
||||
type: typeof source.type === 'string' && source.type.trim() ? source.type.trim() : 'choice',
|
||||
typeLabel: typeof source.typeLabel === 'string' ? source.typeLabel : null,
|
||||
content: typeof source.content === 'string' ? source.content : null,
|
||||
selectedOptions,
|
||||
answerText: typeof source.answerText === 'string' ? source.answerText : null,
|
||||
isCorrect,
|
||||
selfJudged: source.selfJudged === true,
|
||||
correctOptionIndex: typeof numericCorrectOptionIndex === 'number' && Number.isFinite(numericCorrectOptionIndex)
|
||||
? Math.trunc(numericCorrectOptionIndex)
|
||||
: null,
|
||||
correctOptionIndices: normalizeNumberArray(source.correctOptionIndices),
|
||||
correctAnswerText: typeof source.correctAnswerText === 'string' ? source.correctAnswerText : null,
|
||||
explanation: typeof source.explanation === 'string' ? source.explanation : null,
|
||||
answered: source.answered === true,
|
||||
score: round2(finiteNumber(source.score, 0)),
|
||||
totalScore: round2(finiteNumber(source.totalScore, 0)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function storedCompositeSubResults(subResults: CompositeSubResult[]) {
|
||||
return subResults.map(item => ({
|
||||
subQuestionId: item.subQuestionId,
|
||||
order: item.order,
|
||||
selectedOptions: item.selectedOptions,
|
||||
answerText: item.answerText,
|
||||
isCorrect: item.isCorrect,
|
||||
selfJudged: item.selfJudged,
|
||||
answered: item.answered,
|
||||
score: item.score,
|
||||
totalScore: item.totalScore,
|
||||
}));
|
||||
}
|
||||
|
||||
function enrichCompositeSubResults(subResults: CompositeSubResult[], subQuestionsValue: unknown): CompositeSubResult[] {
|
||||
const subQuestionById = new Map(normalizeCompositeSubQuestions(subQuestionsValue).map(item => [item.id, item]));
|
||||
return subResults.map(item => {
|
||||
const subQuestion = subQuestionById.get(item.subQuestionId);
|
||||
if (!subQuestion) return item;
|
||||
return {
|
||||
...item,
|
||||
type: subQuestion.type,
|
||||
typeLabel: subQuestion.typeLabel,
|
||||
content: subQuestion.content,
|
||||
correctOptionIndex: subQuestion.correctOptionIndex,
|
||||
correctOptionIndices: subQuestion.correctOptionIndices,
|
||||
correctAnswerText: subQuestion.answerText,
|
||||
explanation: subQuestion.explanation,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function scaleCompositeSubResults(subResults: CompositeSubResult[], questionScore: number) {
|
||||
if (!subResults.length) return { subResults: [], score: 0 };
|
||||
const rawTotal = round2(subResults.reduce((sum, item) => sum + finiteNumber(item.totalScore, 0), 0));
|
||||
const fallbackTotalEach = round2(questionScore / Math.max(1, subResults.length));
|
||||
const scale = rawTotal > 0 ? questionScore / rawTotal : 1;
|
||||
const scaled = subResults.map(item => {
|
||||
const totalScore = rawTotal > 0 ? round2(finiteNumber(item.totalScore, 0) * scale) : fallbackTotalEach;
|
||||
return {
|
||||
...item,
|
||||
totalScore,
|
||||
score: item.isCorrect === true ? totalScore : 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
subResults: scaled,
|
||||
score: round2(scaled.reduce((sum, item) => sum + item.score, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalBodyBoolean(body: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const value = body[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
@@ -737,12 +1058,14 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
const selectedOptions = optionalStringArray(body, 'selectedOptions');
|
||||
const answerText = optionalString(body, 'answerText');
|
||||
const selfJudgedCorrect = optionalBodyBoolean(body, 'selfJudgedCorrect');
|
||||
const subAnswers = parseSubAnswers(body.subAnswers);
|
||||
const practiceSessionId = optionalString(body, 'practiceSessionId') || null;
|
||||
|
||||
const question = await queryOne<QuestionAnswerRow>(
|
||||
`
|
||||
select q.id as question_id, q.current_version_id as question_version_id,
|
||||
v.correct_option_index, v.correct_option_indices, v.answer_text
|
||||
q.type, v.correct_option_index, v.correct_option_indices, v.answer_text,
|
||||
v.sub_questions
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
|
||||
@@ -757,20 +1080,53 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
|
||||
const result = await transaction(async client => {
|
||||
await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId });
|
||||
const judged = resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect);
|
||||
const compositeEvaluation = subAnswers.length ? evaluateCompositeAnswer(question, subAnswers) : null;
|
||||
if (!compositeEvaluation && normalizeCompositeSubQuestions(question.sub_questions).length) {
|
||||
throw new HttpError(400, 'Composite questions must be answered with subAnswers', 'SUB_ANSWERS_REQUIRED');
|
||||
}
|
||||
if (compositeEvaluation && (selectedOptions.length || answerText || selfJudgedCorrect !== undefined)) {
|
||||
throw new HttpError(400, 'Composite answers must not mix top-level answer fields', 'MIXED_COMPOSITE_ANSWER');
|
||||
}
|
||||
const judged = compositeEvaluation?.isCorrect ?? resolveJudgedAnswer(question, selectedOptions, answerText, selfJudgedCorrect);
|
||||
const answerPayload = compositeEvaluation ? {
|
||||
mode: compositeEvaluation.mode,
|
||||
subAnswers: subAnswers.map(item => ({
|
||||
subQuestionId: item.subQuestionId,
|
||||
selectedOptions: item.selectedOptions,
|
||||
answerText: item.answerText || null,
|
||||
selfJudgedCorrect: item.selfJudgedCorrect,
|
||||
})),
|
||||
subResults: storedCompositeSubResults(compositeEvaluation.subResults),
|
||||
summary: {
|
||||
answeredCount: compositeEvaluation.answeredCount,
|
||||
correctCount: compositeEvaluation.correctCount,
|
||||
wrongCount: compositeEvaluation.wrongCount,
|
||||
unansweredCount: compositeEvaluation.unansweredCount,
|
||||
},
|
||||
} : {};
|
||||
|
||||
const answerResult = await client.query(
|
||||
`
|
||||
insert into public.answer_records (
|
||||
tenant_id, user_id, question_id, question_version_id, practice_session_id,
|
||||
selected_options, answer_text, is_correct
|
||||
selected_options, answer_text, answer_payload, is_correct
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9)
|
||||
returning id, question_id as "questionId", question_version_id as "questionVersionId",
|
||||
selected_options as "selectedOptions", answer_text as "answerText",
|
||||
is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
answer_payload as "answerPayload", is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
`,
|
||||
[tenantId, userId, questionId, question.question_version_id, practiceSessionId, JSON.stringify(selectedOptions), answerText || null, judged],
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
questionId,
|
||||
question.question_version_id,
|
||||
practiceSessionId,
|
||||
JSON.stringify(selectedOptions),
|
||||
answerText || null,
|
||||
JSON.stringify(answerPayload),
|
||||
judged,
|
||||
],
|
||||
);
|
||||
|
||||
if (judged === false) {
|
||||
@@ -814,6 +1170,13 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
return {
|
||||
...answerResult.rows[0],
|
||||
selfJudged: selfJudgedCorrect !== undefined,
|
||||
subResults: compositeEvaluation?.subResults,
|
||||
compositeSummary: compositeEvaluation ? {
|
||||
answeredCount: compositeEvaluation.answeredCount,
|
||||
correctCount: compositeEvaluation.correctCount,
|
||||
wrongCount: compositeEvaluation.wrongCount,
|
||||
unansweredCount: compositeEvaluation.unansweredCount,
|
||||
} : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -938,7 +1301,7 @@ async function buildPracticeSessionReport(
|
||||
v.content, v.explanation,
|
||||
v.correct_option_index as "correctOptionIndex",
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText"
|
||||
v.answer_text as "answerText", v.sub_questions as "subQuestions"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_collection_items ci
|
||||
@@ -955,7 +1318,8 @@ async function buildPracticeSessionReport(
|
||||
`
|
||||
select distinct on (question_id)
|
||||
question_id as "questionId", selected_options as "selectedOptions",
|
||||
answer_text as "answerText", is_correct as "isCorrect",
|
||||
answer_text as "answerText", answer_payload as "answerPayload",
|
||||
is_correct as "isCorrect",
|
||||
answered_at as "answeredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
@@ -980,7 +1344,10 @@ async function buildPracticeSessionReport(
|
||||
const questionScore = round2(resolveQuestionScore({ row: question, section, fallbackScore: fallbackQuestionScore }));
|
||||
const answered = !!answer;
|
||||
const isCorrect = answer?.isCorrect ?? null;
|
||||
const earnedScore = isCorrect === true ? questionScore : 0;
|
||||
const storedPayload = jsonObject(answer?.answerPayload);
|
||||
const storedSubResults = enrichCompositeSubResults(normalizeStoredSubResults(storedPayload.subResults), question.subQuestions);
|
||||
const scaledComposite = storedSubResults.length ? scaleCompositeSubResults(storedSubResults, questionScore) : null;
|
||||
const earnedScore = scaledComposite ? scaledComposite.score : isCorrect === true ? questionScore : 0;
|
||||
const sectionStat = sectionStatsByKey.get(section.key) || {
|
||||
key: section.key,
|
||||
title: section.title,
|
||||
@@ -1026,6 +1393,7 @@ async function buildPracticeSessionReport(
|
||||
correctAnswerText: question.answerText,
|
||||
explanation: question.explanation,
|
||||
content: question.content,
|
||||
subResults: scaledComposite?.subResults,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1276,7 +1644,7 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
|
||||
select distinct on (question_id)
|
||||
id, question_id as "questionId", question_version_id as "questionVersionId",
|
||||
selected_options as "selectedOptions", answer_text as "answerText",
|
||||
is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
answer_payload as "answerPayload", is_correct as "isCorrect", answered_at as "answeredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
@@ -1293,6 +1661,7 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
|
||||
(answers as Array<Record<string, unknown> & { questionId: string }>).map(answer => [answer.questionId, {
|
||||
...answer,
|
||||
selectedOptions: normalizeStringArray(answer.selectedOptions),
|
||||
answerPayload: jsonObject(answer.answerPayload),
|
||||
}]),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user