merge with practice_engine
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import 'dart:math';
|
||||
import '../models/deck.dart';
|
||||
import '../models/question.dart';
|
||||
import '../models/attempt.dart';
|
||||
import '../models/attempt_result.dart';
|
||||
import '../algorithms/weighted_selector.dart';
|
||||
import 'deck_service.dart';
|
||||
|
||||
/// Service for managing attempt/quiz operations.
|
||||
class AttemptService {
|
||||
final WeightedSelector _selector;
|
||||
|
||||
/// Creates an attempt service with an optional random seed.
|
||||
AttemptService({int? seed}) : _selector = WeightedSelector(seed: seed);
|
||||
|
||||
/// Creates a new attempt by selecting questions from the deck.
|
||||
///
|
||||
/// Selects [attemptSize] questions (defaults to deck config default).
|
||||
/// Uses weighted random selection with no duplicates.
|
||||
/// Excludes known questions unless [DeckConfig.includeKnownInAttempts] is true.
|
||||
/// [includeKnown] can override the config setting for this attempt.
|
||||
Attempt createAttempt({
|
||||
required Deck deck,
|
||||
int? attemptSize,
|
||||
bool? includeKnown,
|
||||
}) {
|
||||
final size = attemptSize ?? deck.config.defaultAttemptSize;
|
||||
|
||||
// Filter candidates based on includeKnownInAttempts setting
|
||||
// Use override parameter if provided, otherwise use config
|
||||
final shouldIncludeKnown = includeKnown ?? deck.config.includeKnownInAttempts;
|
||||
final candidates = shouldIncludeKnown
|
||||
? deck.questions
|
||||
: deck.questions.where((q) => !q.isKnown).toList();
|
||||
|
||||
final selected = _selector.selectQuestions(
|
||||
candidates: candidates,
|
||||
count: size,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
);
|
||||
|
||||
return Attempt(
|
||||
id: _generateAttemptId(),
|
||||
questions: selected,
|
||||
startTime: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
/// Processes an attempt result and updates the deck.
|
||||
///
|
||||
/// [answers] is a map of questionId -> userAnswerIndex (for single answer) or questionId -> List<int> (for multiple answers).
|
||||
/// [manualOverrides] is an optional map of questionId -> bool (true if marked as needs practice).
|
||||
/// Returns the updated deck and attempt result.
|
||||
({
|
||||
Deck updatedDeck,
|
||||
AttemptResult result,
|
||||
}) processAttempt({
|
||||
required Deck deck,
|
||||
required Attempt attempt,
|
||||
required Map<String, dynamic> answers,
|
||||
Map<String, bool>? manualOverrides,
|
||||
int? endTime,
|
||||
}) {
|
||||
final overrides = manualOverrides ?? {};
|
||||
final finishTime = endTime ?? DateTime.now().millisecondsSinceEpoch;
|
||||
final timeSpent = finishTime - attempt.startTime;
|
||||
|
||||
final answerResults = <AnswerResult>[];
|
||||
final updatedQuestions = <Question>[];
|
||||
|
||||
// Process each question in the attempt
|
||||
for (final question in attempt.questions) {
|
||||
final userAnswer = answers[question.id];
|
||||
if (userAnswer == null) {
|
||||
// Question not answered, treat as incorrect
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle both single answer (int) and multiple answers (List<int>)
|
||||
final List<int> userAnswerIndices;
|
||||
if (userAnswer is int) {
|
||||
userAnswerIndices = [userAnswer];
|
||||
} else if (userAnswer is List<int>) {
|
||||
userAnswerIndices = userAnswer;
|
||||
} else {
|
||||
// Invalid format, treat as incorrect
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if answer is correct
|
||||
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
||||
final correctIndices = question.correctIndices;
|
||||
final userSet = userAnswerIndices.toSet();
|
||||
final correctSet = correctIndices.toSet();
|
||||
final isCorrect = userSet.length == correctSet.length &&
|
||||
userSet.every((idx) => correctSet.contains(idx));
|
||||
final userMarkedNeedsPractice = overrides[question.id] ?? false;
|
||||
|
||||
// Determine status change
|
||||
final oldIsKnown = question.isKnown;
|
||||
final oldStreak = question.consecutiveCorrect;
|
||||
|
||||
// Update question
|
||||
final updated = userMarkedNeedsPractice
|
||||
? DeckService.updateQuestionWithManualOverride(
|
||||
question: question,
|
||||
isCorrect: isCorrect,
|
||||
userMarkedNeedsPractice: true,
|
||||
config: deck.config,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
)
|
||||
: DeckService.updateQuestionAfterAnswer(
|
||||
question: question,
|
||||
isCorrect: isCorrect,
|
||||
config: deck.config,
|
||||
currentAttemptIndex: deck.currentAttemptIndex,
|
||||
);
|
||||
|
||||
// Determine status change
|
||||
QuestionStatusChange statusChange;
|
||||
if (userMarkedNeedsPractice) {
|
||||
statusChange = QuestionStatusChange.unchanged;
|
||||
} else if (isCorrect) {
|
||||
if (!oldIsKnown && updated.isKnown) {
|
||||
statusChange = QuestionStatusChange.improved;
|
||||
} else if (updated.consecutiveCorrect > oldStreak) {
|
||||
statusChange = QuestionStatusChange.improved;
|
||||
} else {
|
||||
statusChange = QuestionStatusChange.unchanged;
|
||||
}
|
||||
} else {
|
||||
if (oldIsKnown && !updated.isKnown) {
|
||||
statusChange = QuestionStatusChange.regressed;
|
||||
} else if (oldStreak > 0 && updated.consecutiveCorrect == 0) {
|
||||
statusChange = QuestionStatusChange.regressed;
|
||||
} else {
|
||||
statusChange = QuestionStatusChange.unchanged;
|
||||
}
|
||||
}
|
||||
|
||||
answerResults.add(AnswerResult(
|
||||
question: updated,
|
||||
userAnswerIndices: userAnswerIndices,
|
||||
isCorrect: isCorrect,
|
||||
statusChange: statusChange,
|
||||
));
|
||||
|
||||
updatedQuestions.add(updated);
|
||||
}
|
||||
|
||||
// Update deck with new question states
|
||||
final questionMap = Map.fromEntries(
|
||||
deck.questions.map((q) => MapEntry(q.id, q)),
|
||||
);
|
||||
|
||||
for (final updated in updatedQuestions) {
|
||||
questionMap[updated.id] = updated;
|
||||
}
|
||||
|
||||
final allUpdatedQuestions = deck.questions.map((q) {
|
||||
return questionMap[q.id] ?? q;
|
||||
}).toList();
|
||||
|
||||
final updatedDeck = deck.copyWith(
|
||||
questions: allUpdatedQuestions,
|
||||
currentAttemptIndex: deck.currentAttemptIndex + 1,
|
||||
);
|
||||
|
||||
final result = AttemptResult.fromAnswers(
|
||||
results: answerResults,
|
||||
timeSpent: timeSpent,
|
||||
);
|
||||
|
||||
return (
|
||||
updatedDeck: updatedDeck,
|
||||
result: result,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates a unique attempt ID.
|
||||
String _generateAttemptId() {
|
||||
return 'attempt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import '../models/deck.dart';
|
||||
import '../models/question.dart';
|
||||
import '../models/deck_config.dart';
|
||||
import '../algorithms/priority_manager.dart';
|
||||
|
||||
/// Service for managing deck operations.
|
||||
class DeckService {
|
||||
/// Marks a question as known (manual override).
|
||||
///
|
||||
/// Sets streak to threshold and isKnown to true.
|
||||
static Deck markQuestionAsKnown({
|
||||
required Deck deck,
|
||||
required String questionId,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
if (q.id == questionId) {
|
||||
return q.copyWith(
|
||||
consecutiveCorrect: deck.config.requiredConsecutiveCorrect,
|
||||
isKnown: true,
|
||||
);
|
||||
}
|
||||
return q;
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Marks a question as needs practice (manual override).
|
||||
///
|
||||
/// Sets isKnown to false and streak to 0, but keeps priority unchanged.
|
||||
static Deck markQuestionAsNeedsPractice({
|
||||
required Deck deck,
|
||||
required String questionId,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
if (q.id == questionId) {
|
||||
return q.copyWith(
|
||||
isKnown: false,
|
||||
consecutiveCorrect: 0,
|
||||
// priorityPoints remains unchanged
|
||||
);
|
||||
}
|
||||
return q;
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Resets the entire deck.
|
||||
///
|
||||
/// Resets all questions: streak = 0, isKnown = false, priority = 0.
|
||||
/// Optionally resets totalAttempts if [resetAttemptCounts] is true.
|
||||
/// Optionally clears attempt history if [clearAttemptHistory] is true.
|
||||
static Deck resetDeck({
|
||||
required Deck deck,
|
||||
bool resetAttemptCounts = false,
|
||||
bool clearAttemptHistory = false,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
return q.copyWith(
|
||||
consecutiveCorrect: 0,
|
||||
isKnown: false,
|
||||
priorityPoints: 0,
|
||||
lastAttemptIndex: -1,
|
||||
totalCorrectAttempts: resetAttemptCounts ? 0 : q.totalCorrectAttempts,
|
||||
totalAttempts: resetAttemptCounts ? 0 : q.totalAttempts,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return deck.copyWith(
|
||||
questions: updatedQuestions,
|
||||
currentAttemptIndex: 0,
|
||||
attemptHistory: clearAttemptHistory ? [] : deck.attemptHistory,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a question's state after an answer.
|
||||
///
|
||||
/// Applies correctness rules and updates streak, priority, and isKnown.
|
||||
static Question updateQuestionAfterAnswer({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required DeckConfig config,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
Question updated = question;
|
||||
|
||||
if (isCorrect) {
|
||||
// Increment streak
|
||||
final newStreak = question.consecutiveCorrect + 1;
|
||||
updated = updated.copyWith(consecutiveCorrect: newStreak);
|
||||
|
||||
// Update isKnown if streak reaches threshold
|
||||
if (newStreak >= config.requiredConsecutiveCorrect) {
|
||||
updated = updated.copyWith(isKnown: true);
|
||||
}
|
||||
|
||||
// Decrease priority
|
||||
updated = PriorityManager.applyAnswerResult(
|
||||
question: updated,
|
||||
isCorrect: true,
|
||||
config: config,
|
||||
);
|
||||
|
||||
// Update totals
|
||||
updated = updated.copyWith(
|
||||
totalCorrectAttempts: question.totalCorrectAttempts + 1,
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
);
|
||||
} else {
|
||||
// Reset streak
|
||||
updated = updated.copyWith(
|
||||
consecutiveCorrect: 0,
|
||||
isKnown: false,
|
||||
);
|
||||
|
||||
// Increase priority
|
||||
updated = PriorityManager.applyAnswerResult(
|
||||
question: updated,
|
||||
isCorrect: false,
|
||||
config: config,
|
||||
);
|
||||
|
||||
// Update totals
|
||||
updated = updated.copyWith(
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
);
|
||||
}
|
||||
|
||||
// Update lastAttemptIndex
|
||||
updated = updated.copyWith(lastAttemptIndex: currentAttemptIndex);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates a question with manual override after an answer.
|
||||
///
|
||||
/// If user marks as "Needs Practice" even after correct answer,
|
||||
/// ignore correctness and don't increment streak or decrease priority.
|
||||
static Question updateQuestionWithManualOverride({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required bool userMarkedNeedsPractice,
|
||||
required DeckConfig config,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (userMarkedNeedsPractice) {
|
||||
// User marked as needs practice - ignore correctness
|
||||
// Don't increment streak, don't decrease priority
|
||||
// Just update attempt counts and lastAttemptIndex
|
||||
return question.copyWith(
|
||||
totalAttempts: question.totalAttempts + 1,
|
||||
lastAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
} else {
|
||||
// Normal flow
|
||||
return updateQuestionAfterAnswer(
|
||||
question: question,
|
||||
isCorrect: isCorrect,
|
||||
config: config,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user