feat: add mock exam reports

This commit is contained in:
Codex
2026-06-28 23:34:31 +08:00
parent 6a7294224d
commit e55942dcec
8 changed files with 817 additions and 5 deletions

View File

@@ -3,8 +3,11 @@ import {
createPracticeSessionRoute,
favoriteQuestionsRoute,
favoriteWordsRoute,
practiceReportsRoute,
practiceSessionReportRoute,
resolveWrongQuestionRoute,
submitAnswerRoute,
submitPracticeSessionRoute,
toggleFavoriteQuestionRoute,
toggleFavoriteWordRoute,
updateWordProgressRoute,
@@ -15,6 +18,9 @@ import {
export const learningRoutes: RouteDefinition[] = [
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
['GET', '/api/learning/practice-reports', practiceReportsRoute],
['POST', '/api/learning/answers', submitAnswerRoute],
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],

View File

@@ -1,3 +1,4 @@
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
@@ -35,6 +36,115 @@ interface PracticeBlueprintRow {
rules: Record<string, unknown>;
}
interface PracticeSessionReportSessionRow {
id: string;
tenantId: string;
userId: string;
mode: string;
blueprintId: string | null;
collectionId: string | null;
entryId: string | null;
contentNodeId: string | null;
questionIds: unknown;
questionCount: number;
durationMinutes: number | null;
totalScore: string | number | null;
startedAt: string;
finishedAt: string | null;
metadata: Record<string, unknown>;
}
interface PracticeSessionReportRow {
id: string;
tenantId: string;
userId: string;
practiceSessionId: string;
blueprintId: string | null;
collectionId: string | null;
mode: string;
totalQuestions: number;
answeredCount: number;
correctCount: number;
wrongCount: number;
unansweredCount: number;
score: string | number;
totalScore: string | number;
accuracy: string | number;
durationSeconds: number;
startedAt: string | null;
submittedAt: string;
sectionStats: unknown;
questionResults: unknown;
wrongQuestionIds: unknown;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface PracticeSessionReportAnswerRow {
questionId: string;
selectedOptions: unknown;
answerText: string | null;
isCorrect: boolean | null;
answeredAt: string;
}
interface PracticeSessionReportQuestionRow {
questionId: string;
type: string;
typeLabel: string | null;
collectionId: string | null;
sectionKey: string | null;
sortOrder: number | null;
itemScore: string | number | null;
content: string | null;
explanation: string | null;
correctOptionIndex: number | null;
correctOptionIndices: unknown;
answerText: string | null;
}
interface PracticeReportQuestionResult {
questionId: string;
questionType: string;
sectionKey: string;
score: number;
totalScore: number;
answered: boolean;
isCorrect: boolean | null;
selectedOptions: string[];
answerText: string | null;
answeredAt: string | null;
correctOptionIndex: number | null;
correctOptionIndices: number[];
correctAnswerText: string | null;
explanation: string | null;
content: string | null;
}
interface SectionDefinition {
key: string;
title: string | null;
questionType: string | null;
scoreEach: number | null;
sortOrder: number;
}
interface SectionStat {
key: string;
title: string | null;
questionType: string | null;
questionCount: number;
answeredCount: number;
correctCount: number;
wrongCount: number;
unansweredCount: number;
score: number;
totalScore: number;
accuracy: number;
sortOrder: number;
}
interface PracticeAssembly {
mode: string;
targetType: string | null;
@@ -101,12 +211,77 @@ function jsonArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function jsonObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function positiveInt(value: unknown, fallback: number, max = 500) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function finiteNumber(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function round4(value: number) {
return Math.round(value * 10000) / 10000;
}
function idArrayFromJson(value: unknown): string[] {
return normalizeStringArray(value);
}
function sectionDefinitionsFrom(metadata: Record<string, unknown>): SectionDefinition[] {
const assembly = jsonObject(metadata.assembly);
return jsonArray(assembly.sections).map((section, index) => {
const object = jsonObject(section);
const key = String(object.key || object.sectionKey || object.questionType || object.type || `section_${index + 1}`);
const title = typeof object.title === 'string'
? object.title
: typeof object.name === 'string'
? object.name
: null;
const questionType = typeof object.questionType === 'string'
? object.questionType
: typeof object.type === 'string'
? object.type
: null;
const scoreEach = object.scoreEach === undefined && object.score === undefined ? null : finiteNumber(object.scoreEach ?? object.score, 0);
return { key, title, questionType, scoreEach, sortOrder: index };
});
}
function sectionForQuestion(row: PracticeSessionReportQuestionRow, sections: SectionDefinition[]) {
const direct = row.sectionKey ? sections.find(section => section.key === row.sectionKey) : undefined;
if (direct) return direct;
const byType = sections.find(section => section.questionType && section.questionType === row.type);
if (byType) return byType;
return {
key: row.sectionKey || row.type || 'default',
title: row.typeLabel,
questionType: row.type || null,
scoreEach: null,
sortOrder: sections.length,
};
}
function resolveQuestionScore(input: {
row: PracticeSessionReportQuestionRow;
section: SectionDefinition;
fallbackScore: number;
}) {
if (input.row.itemScore !== null && input.row.itemScore !== undefined) return finiteNumber(input.row.itemScore, input.fallbackScore);
if (input.section.scoreEach !== null && Number.isFinite(input.section.scoreEach)) return input.section.scoreEach;
return input.fallbackScore;
}
function modeFrom(value: string) {
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
return 'chapter';
@@ -524,6 +699,417 @@ export async function submitAnswerRoute(ctx: RequestContext) {
return { item: result };
}
function formatReport(row: PracticeSessionReportRow) {
return {
...row,
totalQuestions: Number(row.totalQuestions),
answeredCount: Number(row.answeredCount),
correctCount: Number(row.correctCount),
wrongCount: Number(row.wrongCount),
unansweredCount: Number(row.unansweredCount),
score: finiteNumber(row.score),
totalScore: finiteNumber(row.totalScore),
accuracy: finiteNumber(row.accuracy),
durationSeconds: Number(row.durationSeconds || 0),
sectionStats: jsonArray(row.sectionStats),
questionResults: jsonArray(row.questionResults),
wrongQuestionIds: normalizeStringArray(row.wrongQuestionIds),
metadata: jsonObject(row.metadata),
};
}
async function fetchPracticeReportBySession(
client: { query: pg.PoolClient['query'] },
tenantId: string,
userId: string,
practiceSessionId: string,
) {
const result = await client.query<PracticeSessionReportRow>(
`
select id, tenant_id as "tenantId", user_id as "userId",
practice_session_id as "practiceSessionId",
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
total_questions as "totalQuestions", answered_count as "answeredCount",
correct_count as "correctCount", wrong_count as "wrongCount",
unanswered_count as "unansweredCount",
score, total_score as "totalScore", accuracy,
duration_seconds as "durationSeconds",
started_at as "startedAt", submitted_at as "submittedAt",
section_stats as "sectionStats", question_results as "questionResults",
wrong_question_ids as "wrongQuestionIds", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.practice_session_reports
where tenant_id = $1 and user_id = $2 and practice_session_id = $3
limit 1
`,
[tenantId, userId, practiceSessionId],
);
return result.rows[0] || null;
}
async function buildPracticeSessionReport(
client: pg.PoolClient,
tenantId: string,
userId: string,
practiceSessionId: string,
) {
const existing = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId);
if (existing) return existing;
const sessionResult = await client.query<PracticeSessionReportSessionRow>(
`
select id, tenant_id as "tenantId", user_id as "userId", mode,
blueprint_id as "blueprintId", collection_id as "collectionId",
entry_id as "entryId", content_node_id as "contentNodeId",
question_ids as "questionIds", question_count as "questionCount",
duration_minutes as "durationMinutes", total_score as "totalScore",
started_at as "startedAt", finished_at as "finishedAt", metadata
from public.practice_sessions
where tenant_id = $1 and user_id = $2 and id = $3
for update
`,
[tenantId, userId, practiceSessionId],
);
const session = sessionResult.rows[0];
if (!session) {
throw new HttpError(404, 'Practice session not found', 'PRACTICE_SESSION_NOT_FOUND');
}
const existingAfterLock = await fetchPracticeReportBySession(client, tenantId, userId, practiceSessionId);
if (existingAfterLock) return existingAfterLock;
const questionIds = idArrayFromJson(session.questionIds);
if (questionIds.length === 0) {
throw new HttpError(409, 'Practice session has no question snapshot', 'PRACTICE_SESSION_EMPTY');
}
const sectionDefinitions = sectionDefinitionsFrom(session.metadata);
const fallbackQuestionScore = session.totalScore
? finiteNumber(session.totalScore, questionIds.length) / Math.max(1, questionIds.length)
: 1;
const questionRows = await client.query<PracticeSessionReportQuestionRow>(
`
select q.id as "questionId", q.type, q.type_label as "typeLabel",
ci.collection_id as "collectionId", ci.section_key as "sectionKey",
ci.sort_order as "sortOrder", ci.score as "itemScore",
v.content, v.explanation,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText"
from public.questions q
left join public.question_versions v on v.id = q.current_version_id
left join public.question_collection_items ci
on ci.tenant_id = q.tenant_id
and ci.question_id = q.id
and ci.collection_id = $3::uuid
where q.tenant_id = $1 and q.id = any($2::uuid[])
`,
[tenantId, questionIds, session.collectionId],
);
const questionById = new Map(questionRows.rows.map(row => [row.questionId, row]));
const answerRows = await client.query<PracticeSessionReportAnswerRow>(
`
select distinct on (question_id)
question_id as "questionId", selected_options as "selectedOptions",
answer_text as "answerText", is_correct as "isCorrect",
answered_at as "answeredAt"
from public.answer_records
where tenant_id = $1
and user_id = $2
and practice_session_id = $3
and question_id = any($4::uuid[])
order by question_id, answered_at desc
`,
[tenantId, userId, practiceSessionId, questionIds],
);
const answerByQuestionId = new Map(answerRows.rows.map(row => [row.questionId, row]));
const sectionStatsByKey = new Map<string, SectionStat>();
const questionResults: PracticeReportQuestionResult[] = [];
const wrongQuestionIds: string[] = [];
for (const questionId of questionIds) {
const question = questionById.get(questionId);
if (!question) continue;
const answer = answerByQuestionId.get(questionId) || null;
const section = sectionForQuestion(question, sectionDefinitions);
const questionScore = round2(resolveQuestionScore({ row: question, section, fallbackScore: fallbackQuestionScore }));
const answered = !!answer;
const isCorrect = answer?.isCorrect ?? null;
const earnedScore = isCorrect === true ? questionScore : 0;
const sectionStat = sectionStatsByKey.get(section.key) || {
key: section.key,
title: section.title,
questionType: section.questionType,
questionCount: 0,
answeredCount: 0,
correctCount: 0,
wrongCount: 0,
unansweredCount: 0,
score: 0,
totalScore: 0,
accuracy: 0,
sortOrder: section.sortOrder,
};
sectionStat.questionCount += 1;
sectionStat.totalScore = round2(sectionStat.totalScore + questionScore);
if (answered) sectionStat.answeredCount += 1;
if (isCorrect === true) {
sectionStat.correctCount += 1;
sectionStat.score = round2(sectionStat.score + earnedScore);
} else if (answered) {
sectionStat.wrongCount += 1;
wrongQuestionIds.push(questionId);
} else {
sectionStat.unansweredCount += 1;
}
sectionStatsByKey.set(section.key, sectionStat);
questionResults.push({
questionId,
questionType: question.type,
sectionKey: section.key,
score: earnedScore,
totalScore: questionScore,
answered,
isCorrect,
selectedOptions: normalizeStringArray(answer?.selectedOptions),
answerText: answer?.answerText || null,
answeredAt: answer?.answeredAt || null,
correctOptionIndex: question.correctOptionIndex,
correctOptionIndices: normalizeNumberArray(question.correctOptionIndices),
correctAnswerText: question.answerText,
explanation: question.explanation,
content: question.content,
});
}
const sectionStats = [...sectionStatsByKey.values()]
.sort((left, right) => left.sortOrder - right.sortOrder || left.key.localeCompare(right.key))
.map(section => ({
...section,
unansweredCount: Math.max(0, section.questionCount - section.answeredCount),
accuracy: round4(section.questionCount ? section.correctCount / section.questionCount : 0),
score: round2(section.score),
totalScore: round2(section.totalScore),
}));
const totalQuestions = questionResults.length;
const answeredCount = questionResults.filter(item => item.answered).length;
const correctCount = questionResults.filter(item => item.isCorrect === true).length;
const wrongCount = questionResults.filter(item => item.answered && item.isCorrect !== true).length;
const unansweredCount = Math.max(0, totalQuestions - answeredCount);
const score = round2(questionResults.reduce((sum, item) => sum + item.score, 0));
const computedTotalScore = round2(questionResults.reduce((sum, item) => sum + item.totalScore, 0));
const configuredTotalScore = session.totalScore ? round2(finiteNumber(session.totalScore, computedTotalScore)) : computedTotalScore;
const submittedAt = new Date();
const startedAt = new Date(session.startedAt);
const durationSeconds = Number.isFinite(startedAt.getTime())
? Math.max(0, Math.trunc((submittedAt.getTime() - startedAt.getTime()) / 1000))
: 0;
const reportResult = await client.query<PracticeSessionReportRow>(
`
insert into public.practice_session_reports (
tenant_id, user_id, practice_session_id, blueprint_id, collection_id, mode,
total_questions, answered_count, correct_count, wrong_count, unanswered_count,
score, total_score, accuracy, duration_seconds, started_at, submitted_at,
section_stats, question_results, wrong_question_ids, metadata
)
values (
$1, $2, $3, $4::uuid, $5::uuid, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18::jsonb, $19::jsonb, $20::jsonb, $21::jsonb
)
returning id, tenant_id as "tenantId", user_id as "userId",
practice_session_id as "practiceSessionId",
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
total_questions as "totalQuestions", answered_count as "answeredCount",
correct_count as "correctCount", wrong_count as "wrongCount",
unanswered_count as "unansweredCount",
score, total_score as "totalScore", accuracy,
duration_seconds as "durationSeconds",
started_at as "startedAt", submitted_at as "submittedAt",
section_stats as "sectionStats", question_results as "questionResults",
wrong_question_ids as "wrongQuestionIds", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
tenantId,
userId,
practiceSessionId,
session.blueprintId,
session.collectionId,
session.mode,
totalQuestions,
answeredCount,
correctCount,
wrongCount,
unansweredCount,
score,
configuredTotalScore,
round4(totalQuestions ? correctCount / totalQuestions : 0),
durationSeconds,
session.startedAt,
submittedAt.toISOString(),
JSON.stringify(sectionStats),
JSON.stringify(questionResults),
JSON.stringify(wrongQuestionIds),
JSON.stringify({
scoringVersion: 1,
computedTotalScore,
configuredTotalScore,
entryId: session.entryId,
contentNodeId: session.contentNodeId,
}),
],
);
const report = reportResult.rows[0];
for (const section of sectionStats) {
await client.query(
`
insert into public.practice_session_report_sections (
tenant_id, report_id, practice_session_id, section_key, section_name, question_type,
question_count, answered_count, correct_count, wrong_count, unanswered_count,
score, total_score, accuracy, sort_order, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, '{}'::jsonb)
on conflict (tenant_id, report_id, section_key)
do update set section_name = excluded.section_name,
question_type = excluded.question_type,
question_count = excluded.question_count,
answered_count = excluded.answered_count,
correct_count = excluded.correct_count,
wrong_count = excluded.wrong_count,
unanswered_count = excluded.unanswered_count,
score = excluded.score,
total_score = excluded.total_score,
accuracy = excluded.accuracy,
sort_order = excluded.sort_order,
updated_at = now()
`,
[
tenantId,
report.id,
practiceSessionId,
section.key,
section.title,
section.questionType,
section.questionCount,
section.answeredCount,
section.correctCount,
section.wrongCount,
section.unansweredCount,
section.score,
section.totalScore,
section.accuracy,
section.sortOrder,
],
);
}
await client.query(
`
update public.practice_sessions
set finished_at = coalesce(finished_at, $4::timestamptz),
metadata = metadata || jsonb_build_object(
'submittedReportId', $5::uuid,
'score', $6::numeric,
'accuracy', $7::numeric,
'submittedAt', $4::timestamptz
)
where tenant_id = $1 and user_id = $2 and id = $3
`,
[tenantId, userId, practiceSessionId, submittedAt.toISOString(), report.id, score, round4(totalQuestions ? correctCount / totalQuestions : 0)],
);
return report;
}
export async function submitPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const practiceSessionId = requiredString(body, 'practiceSessionId');
const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId));
return { item: formatReport(report) };
}
export async function practiceSessionReportRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const practiceSessionId = stringParam(ctx, 'practiceSessionId');
if (!practiceSessionId) {
throw new HttpError(400, 'practiceSessionId is required', 'PRACTICE_SESSION_ID_REQUIRED');
}
const report = await queryOne<PracticeSessionReportRow>(
`
select id, tenant_id as "tenantId", user_id as "userId",
practice_session_id as "practiceSessionId",
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
total_questions as "totalQuestions", answered_count as "answeredCount",
correct_count as "correctCount", wrong_count as "wrongCount",
unanswered_count as "unansweredCount",
score, total_score as "totalScore", accuracy,
duration_seconds as "durationSeconds",
started_at as "startedAt", submitted_at as "submittedAt",
section_stats as "sectionStats", question_results as "questionResults",
wrong_question_ids as "wrongQuestionIds", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.practice_session_reports
where tenant_id = $1 and user_id = $2 and practice_session_id = $3
limit 1
`,
[tenantId, userId, practiceSessionId],
);
if (!report) {
throw new HttpError(404, 'Practice session report not found', 'PRACTICE_REPORT_NOT_FOUND');
}
return { item: formatReport(report) };
}
export async function practiceReportsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const blueprintId = stringParam(ctx, 'blueprintId');
const mode = stringParam(ctx, 'mode');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query<PracticeSessionReportRow>(
`
select id, tenant_id as "tenantId", user_id as "userId",
practice_session_id as "practiceSessionId",
blueprint_id as "blueprintId", collection_id as "collectionId", mode,
total_questions as "totalQuestions", answered_count as "answeredCount",
correct_count as "correctCount", wrong_count as "wrongCount",
unanswered_count as "unansweredCount",
score, total_score as "totalScore", accuracy,
duration_seconds as "durationSeconds",
started_at as "startedAt", submitted_at as "submittedAt",
section_stats as "sectionStats", question_results as "questionResults",
wrong_question_ids as "wrongQuestionIds", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.practice_session_reports
where tenant_id = $1 and user_id = $2
and ($3::uuid is null or blueprint_id = $3::uuid)
and ($4::text = '' or mode = $4::text)
order by submitted_at desc
limit $5
`,
[tenantId, userId, blueprintId || null, mode, limit],
);
return { items: items.map(formatReport) };
}
export async function favoriteQuestionsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);