feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Advisory evaluation for open-ended learner answers. Local rules still
|
||||
/// decide whether an attempt counts as evidence or changes learning state.
|
||||
/// Evaluation for open-ended learner answers. Lesson submissions combine this
|
||||
/// verdict with local task rules before recording independent evidence.
|
||||
class AiAnswerEvaluationCapability {
|
||||
const AiAnswerEvaluationCapability._(this._service);
|
||||
|
||||
|
||||
@@ -964,8 +964,9 @@ class AiService {
|
||||
'''Evaluate a beginner's open-ended English writing response using the input JSON.
|
||||
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.lessonId.
|
||||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||||
Assess only whether the learner expressed input.taskPrompt. Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
||||
verdict must be accepted, rewrite, or uncertain. Use accepted only when the answer meets input.taskPrompt and has no spelling or grammar mistakes; ignore minor capitalisation and final punctuation. Use rewrite when the task is missed or there is a spelling or grammar mistake. Use uncertain when the intended meaning cannot be determined.
|
||||
feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions of actual issues.
|
||||
Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
||||
final content = await _requestStructured(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
@@ -985,8 +986,9 @@ Assess only whether the learner expressed input.taskPrompt. Do not claim pronunc
|
||||
}
|
||||
|
||||
/// Checks a review answer or dialogue turn for spelling and grammar. It
|
||||
/// reuses the writing feedback schema, keyed by [answerId], and stays
|
||||
/// advisory: the local check still decides whether the answer counts.
|
||||
/// reuses the writing feedback schema, keyed by [answerId]. Lesson answers
|
||||
/// need both a local task match and an accepted verdict for independent
|
||||
/// evidence; the caller makes that decision on-device.
|
||||
Future<WritingAiFeedback?> answerFeedback({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
|
||||
@@ -53,12 +53,19 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
LessonStep lessonStep = LessonStep.preview;
|
||||
int previewIndex = 0;
|
||||
bool lessonListeningComplete = false;
|
||||
bool listeningAudioPlayed = false;
|
||||
bool listeningAudioUnavailable = false;
|
||||
ListeningSupport listeningSupport = ListeningSupport.none;
|
||||
int listeningAttempts = 0;
|
||||
bool sentenceBuildUsed = false;
|
||||
int sentenceBuildIndex = 0;
|
||||
bool lessonSpeakingComplete = false;
|
||||
bool lessonReadingComplete = false;
|
||||
bool lessonWritingComplete = false;
|
||||
bool lessonDialogueComplete = false;
|
||||
bool independentAttemptComplete = false;
|
||||
bool independentAttemptAssisted = false;
|
||||
bool independentAttemptPending = false;
|
||||
bool independentAttemptSpoken = false;
|
||||
String lessonWritingDraft = '';
|
||||
String independentAttemptDraft = '';
|
||||
@@ -101,7 +108,8 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
}
|
||||
|
||||
class AppState extends _AppStateData
|
||||
with _ReviewAndMastery,
|
||||
with
|
||||
_ReviewAndMastery,
|
||||
_ReceptiveWords,
|
||||
_AssessmentProgress,
|
||||
_LessonProgress,
|
||||
@@ -245,10 +253,14 @@ class AppState extends _AppStateData
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Applies the placement result. The start may land on any loaded lesson
|
||||
/// across A0–B1, and placement never counts as passing a lesson.
|
||||
/// Placement never marks an earlier lesson as passed or opens draft content.
|
||||
void setPlacementStartLesson(String lessonId) {
|
||||
if (!allLessons.any((lesson) => lesson.id == lessonId)) return;
|
||||
if (!allLessons.any(
|
||||
(lesson) =>
|
||||
lesson.id == lessonId && lesson.status == ContentStatus.approved,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
placementStartLessonId = lessonId;
|
||||
activeLessonId = lessonId;
|
||||
_resetLessonFlow();
|
||||
@@ -312,12 +324,19 @@ class AppState extends _AppStateData
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonListeningComplete = false;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
lessonSpeakingComplete = false;
|
||||
lessonReadingComplete = false;
|
||||
lessonWritingComplete = false;
|
||||
lessonDialogueComplete = false;
|
||||
independentAttemptComplete = false;
|
||||
independentAttemptAssisted = false;
|
||||
independentAttemptPending = false;
|
||||
independentAttemptSpoken = false;
|
||||
mastery.clear();
|
||||
notifyListeners();
|
||||
|
||||
@@ -90,6 +90,7 @@ mixin _LessonProgress
|
||||
final lessons = allLessons;
|
||||
final index = lessons.indexWhere((lesson) => lesson.id == id);
|
||||
if (index < 0) return false;
|
||||
if (lessons[index].status != ContentStatus.approved) return false;
|
||||
// The placement start and everything before it are open, so a learner
|
||||
// placed further ahead can still go back to easier lessons.
|
||||
if (index == 0 || index <= _lessonIndex(placementStartLessonId)) {
|
||||
@@ -110,8 +111,9 @@ mixin _LessonProgress
|
||||
bool get a0LessonsComplete =>
|
||||
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
bool get allLessonsComplete =>
|
||||
allLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
bool get allLessonsComplete => allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
void openLesson(String id) {
|
||||
if (!isLessonUnlocked(id)) return;
|
||||
@@ -134,35 +136,89 @@ mixin _LessonProgress
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningPlayback({bool slow = false}) {
|
||||
listeningAudioPlayed = true;
|
||||
listeningAudioUnavailable = false;
|
||||
if (slow && listeningSupport == ListeningSupport.none) {
|
||||
listeningSupport = ListeningSupport.slow;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningAudioUnavailable() {
|
||||
listeningAudioUnavailable = true;
|
||||
listeningSupport = ListeningSupport.transcript;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void revealListeningTranscript() {
|
||||
listeningSupport = ListeningSupport.transcript;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordListeningMiss() {
|
||||
listeningAttempts++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recordSentenceBuildProgress(int index) {
|
||||
sentenceBuildUsed = true;
|
||||
sentenceBuildIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// [recognized] is true when the learner picked the right meaning on the
|
||||
/// first try, which is recognition evidence for the segment's targets.
|
||||
/// first try, which is recognition evidence for explicitly linked items.
|
||||
void completeListening({bool recognized = false}) {
|
||||
_introduceLessonTargets();
|
||||
if (recognized && !lessonListeningComplete) {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
final independent =
|
||||
recognized &&
|
||||
listeningAudioPlayed &&
|
||||
listeningSupport == ListeningSupport.none &&
|
||||
listeningAttempts == 0;
|
||||
if (!lessonListeningComplete) {
|
||||
final targetIds = activityBySegmentId(_activeSegmentId).listeningItemIds;
|
||||
_recordActivityEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: 'listening-check',
|
||||
skill: '听辨识别',
|
||||
outcome: EvidenceKind.independentSuccess,
|
||||
skill: targetIds.isEmpty ? '听力理解' : '听辨识别',
|
||||
outcome: independent
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.assisted,
|
||||
assisted: !independent,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: listeningAttempts == 0,
|
||||
attemptCount: listeningAttempts + 1,
|
||||
);
|
||||
}
|
||||
lessonListeningComplete = true;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
lessonStep = LessonStep.speaking;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Follow-reading repeats a model sentence, so it is always assisted
|
||||
/// practice and never independent speaking evidence.
|
||||
void completeSpeaking() {
|
||||
void completeSpeaking({bool practiced = false}) {
|
||||
if (lessonSpeakingComplete) return;
|
||||
lessonSpeakingComplete = true;
|
||||
lessonStep = LessonStep.reading;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
taskSuffix: 'speaking',
|
||||
skill: '口语表达',
|
||||
outcome: EvidenceKind.assisted,
|
||||
assisted: true,
|
||||
);
|
||||
final targetIds = activityBySegmentId(_activeSegmentId).speakingItemIds;
|
||||
if (practiced) {
|
||||
_recordActivityEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: 'speaking',
|
||||
skill: targetIds.isEmpty ? '句子跟读' : '口语表达',
|
||||
outcome: EvidenceKind.assisted,
|
||||
assisted: true,
|
||||
usedSentenceBuild: sentenceBuildUsed,
|
||||
);
|
||||
}
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -207,7 +263,11 @@ mixin _LessonProgress
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeWriting({bool assisted = false, String? rawAnswer}) {
|
||||
void completeWriting({
|
||||
bool assisted = false,
|
||||
bool verified = true,
|
||||
String? rawAnswer,
|
||||
}) {
|
||||
lessonWritingComplete = true;
|
||||
lessonWritingDraft = '';
|
||||
lessonStep = LessonStep.dialogue;
|
||||
@@ -215,7 +275,9 @@ mixin _LessonProgress
|
||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||
taskSuffix: 'writing',
|
||||
skill: '写作表达',
|
||||
outcome: assisted
|
||||
outcome: !verified
|
||||
? EvidenceKind.pending
|
||||
: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
@@ -239,6 +301,7 @@ mixin _LessonProgress
|
||||
|
||||
void completeIndependentAttempt({
|
||||
required bool assisted,
|
||||
bool verified = true,
|
||||
bool spoken = false,
|
||||
String? rawAnswer,
|
||||
String? recordingPath,
|
||||
@@ -246,14 +309,17 @@ mixin _LessonProgress
|
||||
independentAttemptComplete = true;
|
||||
independentAttemptDraft = '';
|
||||
independentAttemptAssisted = assisted;
|
||||
independentAttemptSpoken = spoken && !assisted;
|
||||
independentAttemptPending = !verified;
|
||||
independentAttemptSpoken = spoken && !assisted && verified;
|
||||
lessonStep = LessonStep.complete;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||
taskSuffix: 'independent',
|
||||
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
||||
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
||||
outcome: assisted
|
||||
outcome: !verified
|
||||
? EvidenceKind.pending
|
||||
: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
rawAnswer: rawAnswer,
|
||||
@@ -361,7 +427,9 @@ mixin _LessonProgress
|
||||
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
|
||||
|
||||
String? _nextIncompleteLessonId() {
|
||||
final lessons = allLessons;
|
||||
final lessons = allLessons
|
||||
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||
.toList();
|
||||
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
||||
final ordered = [...lessons.skip(start), ...lessons.take(start)];
|
||||
return ordered
|
||||
@@ -374,12 +442,19 @@ mixin _LessonProgress
|
||||
lessonStep = LessonStep.preview;
|
||||
previewIndex = 0;
|
||||
lessonListeningComplete = false;
|
||||
listeningAudioPlayed = false;
|
||||
listeningAudioUnavailable = false;
|
||||
listeningSupport = ListeningSupport.none;
|
||||
listeningAttempts = 0;
|
||||
sentenceBuildUsed = false;
|
||||
sentenceBuildIndex = 0;
|
||||
lessonSpeakingComplete = false;
|
||||
lessonReadingComplete = false;
|
||||
lessonWritingComplete = false;
|
||||
lessonDialogueComplete = false;
|
||||
independentAttemptComplete = false;
|
||||
independentAttemptAssisted = false;
|
||||
independentAttemptPending = false;
|
||||
independentAttemptSpoken = false;
|
||||
lessonWritingDraft = '';
|
||||
independentAttemptDraft = '';
|
||||
@@ -513,6 +588,10 @@ mixin _LessonProgress
|
||||
String inputMode = 'text',
|
||||
String? recordingPath,
|
||||
bool assisted = false,
|
||||
ListeningSupport? listeningSupport,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
bool usedSentenceBuild = false,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
for (var index = 0; index < targetIds.length; index++) {
|
||||
@@ -529,6 +608,10 @@ mixin _LessonProgress
|
||||
rawAnswer: rawAnswer,
|
||||
recordingPath: recordingPath,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
),
|
||||
);
|
||||
// The row goes in first: status is derived from the attempt history.
|
||||
@@ -536,6 +619,52 @@ mixin _LessonProgress
|
||||
}
|
||||
}
|
||||
|
||||
/// A comprehension question or model sentence without an exact core-item
|
||||
/// link is still visible in learning records, but cannot change mastery.
|
||||
void _recordActivityEvidence({
|
||||
required List<String> targetIds,
|
||||
required String taskSuffix,
|
||||
required String skill,
|
||||
required EvidenceKind outcome,
|
||||
bool assisted = false,
|
||||
ListeningSupport? listeningSupport,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
bool usedSentenceBuild = false,
|
||||
}) {
|
||||
if (targetIds.isNotEmpty) {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: targetIds,
|
||||
taskSuffix: taskSuffix,
|
||||
skill: skill,
|
||||
outcome: outcome,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
attemptEvidence.add(
|
||||
AttemptEvidence(
|
||||
id: 'lesson-$_activeSegmentId-$taskSuffix-${now.microsecondsSinceEpoch}',
|
||||
itemId: 'segment:$_activeSegmentId',
|
||||
taskId: 'lesson-$_activeSegmentId-$taskSuffix',
|
||||
skill: skill,
|
||||
inputMode: 'text',
|
||||
outcome: outcome,
|
||||
createdAt: now,
|
||||
assisted: assisted,
|
||||
listeningSupport: listeningSupport,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
usedSentenceBuild: usedSentenceBuild,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _introduceLessonTargets() {
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
|
||||
@@ -139,11 +139,19 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
void completeReview(
|
||||
ReviewItem item, {
|
||||
required bool assisted,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
ListeningSupport? listeningSupport,
|
||||
String? rawAnswer,
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
}) {
|
||||
assisted =
|
||||
assisted ||
|
||||
(listeningSupport != null &&
|
||||
listeningSupport != ListeningSupport.none) ||
|
||||
(attemptCount != null && attemptCount > 1);
|
||||
final index = _queueIndexOf(item);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
@@ -175,7 +183,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
: current.successfulReviews;
|
||||
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
|
||||
// review is due one day after teaching, then 2, 4 and 7 days apart, and
|
||||
// every 30 days once all four checkpoints are passed.
|
||||
// the first mastered audit is 30 days later; later clean, first-try
|
||||
// audits can stretch to 45 and then 60 days.
|
||||
const intervals = [2, 4, 7];
|
||||
final intervalIndex = (nextSuccesses - 1)
|
||||
.clamp(0, intervals.length - 1)
|
||||
@@ -187,7 +196,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
masteryItem?.status == MasteryStatus.master
|
||||
? 7
|
||||
: nextSuccesses >= 4
|
||||
? 30
|
||||
? (firstTry == true && nextSuccesses >= 5
|
||||
? (nextSuccesses >= 6 ? 60 : 45)
|
||||
: 30)
|
||||
: intervals[intervalIndex];
|
||||
// The next check of a core item uses a different reviewed situation, so
|
||||
// later successes also show the item works outside the lesson's context.
|
||||
@@ -211,6 +222,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess,
|
||||
assisted: assisted,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
listeningSupport: listeningSupport,
|
||||
rawAnswer: rawAnswer,
|
||||
inputMode: inputMode,
|
||||
originalTranscript: originalTranscript,
|
||||
@@ -273,7 +287,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
/// next real check still happens tomorrow on a different task.
|
||||
void _scheduleSessionRetry(ReviewItem item) {
|
||||
if (reviewQueue.any(
|
||||
(candidate) => candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
(candidate) =>
|
||||
candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
@@ -425,6 +440,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
String inputMode = 'text',
|
||||
String? originalTranscript,
|
||||
String? recordingPath,
|
||||
bool? firstTry,
|
||||
int? attemptCount,
|
||||
ListeningSupport? listeningSupport,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final spoken = originalTranscript != null;
|
||||
@@ -444,6 +462,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
originalTranscript: originalTranscript,
|
||||
transcriptConfirmed: spoken && originalTranscript == rawAnswer,
|
||||
transcriptEdited: spoken && originalTranscript != rawAnswer,
|
||||
firstTry: firstTry,
|
||||
attemptCount: attemptCount,
|
||||
listeningSupport: listeningSupport,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -523,8 +544,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
||||
final index = reviewQueue.indexWhere(
|
||||
(item) =>
|
||||
item.id == variant.targetItemId &&
|
||||
item.kind == ReviewKind.checkpoint,
|
||||
item.id == variant.targetItemId && item.kind == ReviewKind.checkpoint,
|
||||
);
|
||||
if (index < 0) return;
|
||||
final current = reviewQueue[index];
|
||||
@@ -620,8 +640,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
if (!isReview) continue;
|
||||
// The recognition gate and the contrast question are 「认识」 evidence
|
||||
// only; producing the item is what earns a checkpoint.
|
||||
if (event.skill == recognitionGateSkill ||
|
||||
event.skill == contrastSkill) {
|
||||
if (event.skill == recognitionGateSkill || event.skill == contrastSkill) {
|
||||
continue;
|
||||
}
|
||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||
|
||||
@@ -142,6 +142,17 @@ extension _AppStateSnapshot on AppState {
|
||||
);
|
||||
}
|
||||
lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false;
|
||||
listeningAudioPlayed = data['listeningAudioPlayed'] as bool? ?? false;
|
||||
listeningAudioUnavailable =
|
||||
data['listeningAudioUnavailable'] as bool? ?? false;
|
||||
listeningSupport = _enumValue(
|
||||
ListeningSupport.values,
|
||||
data['listeningSupport'] as String?,
|
||||
ListeningSupport.none,
|
||||
);
|
||||
listeningAttempts = data['listeningAttempts'] as int? ?? 0;
|
||||
sentenceBuildUsed = data['sentenceBuildUsed'] as bool? ?? false;
|
||||
sentenceBuildIndex = data['sentenceBuildIndex'] as int? ?? 0;
|
||||
lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false;
|
||||
lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false;
|
||||
lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false;
|
||||
@@ -150,6 +161,8 @@ extension _AppStateSnapshot on AppState {
|
||||
data['independentAttemptComplete'] as bool? ?? false;
|
||||
independentAttemptAssisted =
|
||||
data['independentAttemptAssisted'] as bool? ?? false;
|
||||
independentAttemptPending =
|
||||
data['independentAttemptPending'] as bool? ?? false;
|
||||
independentAttemptSpoken =
|
||||
data['independentAttemptSpoken'] as bool? ?? false;
|
||||
lessonWritingDraft = data['lessonWritingDraft'] as String? ?? '';
|
||||
@@ -190,6 +203,16 @@ extension _AppStateSnapshot on AppState {
|
||||
transcriptConfirmed:
|
||||
item['transcriptConfirmed'] as bool? ?? false,
|
||||
transcriptEdited: item['transcriptEdited'] as bool? ?? false,
|
||||
listeningSupport: item['listeningSupport'] == null
|
||||
? null
|
||||
: _enumValue(
|
||||
ListeningSupport.values,
|
||||
item['listeningSupport'] as String?,
|
||||
ListeningSupport.none,
|
||||
),
|
||||
firstTry: item['firstTry'] as bool?,
|
||||
attemptCount: item['attemptCount'] as int?,
|
||||
usedSentenceBuild: item['usedSentenceBuild'] as bool? ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -234,15 +257,17 @@ extension _AppStateSnapshot on AppState {
|
||||
misses: item['misses'] as int? ?? 0,
|
||||
// Snapshots written before the spacing ladder carry no step;
|
||||
// place them on the rung their status implies.
|
||||
step: item['step'] as int? ?? switch (_enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
)) {
|
||||
WordStatus.newWord => 0,
|
||||
WordStatus.recognized => 1,
|
||||
WordStatus.familiar => 4,
|
||||
},
|
||||
step:
|
||||
item['step'] as int? ??
|
||||
switch (_enumValue(
|
||||
WordStatus.values,
|
||||
item['status'] as String?,
|
||||
WordStatus.newWord,
|
||||
)) {
|
||||
WordStatus.newWord => 0,
|
||||
WordStatus.recognized => 1,
|
||||
WordStatus.familiar => 4,
|
||||
},
|
||||
startedAt: DateTime.tryParse(
|
||||
item['startedAt'] as String? ?? '',
|
||||
),
|
||||
@@ -396,12 +421,19 @@ extension _AppStateSnapshot on AppState {
|
||||
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
||||
'activeSegmentIndexes': activeSegmentIndexes,
|
||||
'lessonListeningComplete': lessonListeningComplete,
|
||||
'listeningAudioPlayed': listeningAudioPlayed,
|
||||
'listeningAudioUnavailable': listeningAudioUnavailable,
|
||||
'listeningSupport': listeningSupport.name,
|
||||
'listeningAttempts': listeningAttempts,
|
||||
'sentenceBuildUsed': sentenceBuildUsed,
|
||||
'sentenceBuildIndex': sentenceBuildIndex,
|
||||
'lessonSpeakingComplete': lessonSpeakingComplete,
|
||||
'lessonReadingComplete': lessonReadingComplete,
|
||||
'lessonWritingComplete': lessonWritingComplete,
|
||||
'lessonDialogueComplete': lessonDialogueComplete,
|
||||
'independentAttemptComplete': independentAttemptComplete,
|
||||
'independentAttemptAssisted': independentAttemptAssisted,
|
||||
'independentAttemptPending': independentAttemptPending,
|
||||
'independentAttemptSpoken': independentAttemptSpoken,
|
||||
'lessonWritingDraft': lessonWritingDraft,
|
||||
'independentAttemptDraft': independentAttemptDraft,
|
||||
@@ -464,6 +496,10 @@ extension _AppStateSnapshot on AppState {
|
||||
'originalTranscript': entry.originalTranscript,
|
||||
'transcriptConfirmed': entry.transcriptConfirmed,
|
||||
'transcriptEdited': entry.transcriptEdited,
|
||||
'listeningSupport': entry.listeningSupport?.name,
|
||||
'firstTry': entry.firstTry,
|
||||
'attemptCount': entry.attemptCount,
|
||||
'usedSentenceBuild': entry.usedSentenceBuild,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@@ -549,7 +585,11 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
|
||||
hint: data['hint'] as String,
|
||||
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
||||
skill: data['skill'] as String,
|
||||
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
|
||||
kind: _enumValue(
|
||||
ReviewKind.values,
|
||||
data['kind'] as String?,
|
||||
ReviewKind.checkpoint,
|
||||
),
|
||||
attempts: data['attempts'] as int? ?? 0,
|
||||
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
||||
variantIndex: data['variantIndex'] as int? ?? 0,
|
||||
|
||||
@@ -141,19 +141,23 @@ mixin _ReceptiveWords on _AppStateData {
|
||||
return WordStatus.recognized;
|
||||
}
|
||||
|
||||
DateTime _tomorrow(DateTime now) =>
|
||||
DateTime(now.year, now.month, now.day).add(const Duration(days: 1, hours: 9));
|
||||
DateTime _tomorrow(DateTime now) => DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).add(const Duration(days: 1, hours: 9));
|
||||
|
||||
/// Words whose check has come due, weakest first.
|
||||
List<String> get dueWords {
|
||||
final now = DateTime.now();
|
||||
final due = wordKnowledge.values
|
||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
|
||||
return a.dueAt!.compareTo(b.dueAt!);
|
||||
});
|
||||
final due =
|
||||
wordKnowledge.values
|
||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
|
||||
return a.dueAt!.compareTo(b.dueAt!);
|
||||
});
|
||||
return [for (final word in due) word.id];
|
||||
}
|
||||
|
||||
@@ -165,10 +169,13 @@ mixin _ReceptiveWords on _AppStateData {
|
||||
|
||||
/// Words that have been missed and are not 「熟悉」 yet.
|
||||
List<String> get shakyWords {
|
||||
final shaky = wordKnowledge.values
|
||||
.where((word) => word.misses > 0 && word.status != WordStatus.familiar)
|
||||
.toList()
|
||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||
final shaky =
|
||||
wordKnowledge.values
|
||||
.where(
|
||||
(word) => word.misses > 0 && word.status != WordStatus.familiar,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||
return [for (final word in shaky) word.id];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ class AssessmentTask {
|
||||
skill: AssessmentSkill.values.byName(json['skill'] as String),
|
||||
prompt: json['prompt'] as String? ?? '',
|
||||
audio: json['audio'] as String?,
|
||||
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
|
||||
choices: [
|
||||
for (final choice in json['choices'] as List? ?? const []) '$choice',
|
||||
],
|
||||
answerIndex: json['answerIndex'] as int?,
|
||||
accept: [
|
||||
for (final group in json['accept'] as List? ?? const [])
|
||||
@@ -102,10 +104,7 @@ AssessmentPack? replacementFor(String packId) =>
|
||||
/// The pack whose results [packId] counts toward: the original of a
|
||||
/// replacement pack, otherwise [packId] itself.
|
||||
String canonicalAssessmentPackId(String packId) =>
|
||||
assessmentPacks
|
||||
.where((pack) => pack.id == packId)
|
||||
.firstOrNull
|
||||
?.replaces ??
|
||||
assessmentPacks.where((pack) => pack.id == packId).firstOrNull?.replaces ??
|
||||
packId;
|
||||
|
||||
/// Local check of an open task with the same rule engine as the lessons: it
|
||||
|
||||
@@ -63,13 +63,7 @@ void registerSavedWord(
|
||||
required String zh,
|
||||
required String level,
|
||||
}) {
|
||||
receptiveWordRegistry[id] = (
|
||||
level: level,
|
||||
unit: '',
|
||||
en: en,
|
||||
zh: zh,
|
||||
ipa: '',
|
||||
);
|
||||
receptiveWordRegistry[id] = (level: level, unit: '', en: en, zh: zh, ipa: '');
|
||||
}
|
||||
|
||||
/// Registers one word of the standalone word bank. Unlike a unit's receptive
|
||||
@@ -191,9 +185,10 @@ WordQuestion? receptiveQuestion(
|
||||
if (wrong.isEmpty) return null;
|
||||
// A different variant asks the same word against other meanings, which is
|
||||
// what 「换题、换语境」 means for a word this small.
|
||||
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
|
||||
.take(2)
|
||||
.toList();
|
||||
final rotated = [
|
||||
...wrong.skip(variant % wrong.length),
|
||||
...wrong.take(variant % wrong.length),
|
||||
].take(2).toList();
|
||||
// Asked from the meaning, the options are spellings and the prompt is the
|
||||
// Chinese; asked either other way, it is the other way round.
|
||||
final asksSpelling = mode == WordAskMode.recall;
|
||||
@@ -339,7 +334,6 @@ const spokenRecallSkill = '口头回忆';
|
||||
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
|
||||
const recognitionGateSkill = '听辨识别';
|
||||
|
||||
|
||||
/// The skill of a contrast question: a situation is described and the learner
|
||||
/// picks which taught sentence it calls for. It shows the learner knows *when*
|
||||
/// to say something, which is not the same as being able to produce it, so it
|
||||
|
||||
@@ -12,7 +12,6 @@ class LessonSegment {
|
||||
this.estimatedMinutes = 12,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final List<String> targetItemIds;
|
||||
final List<String> previewItemIds;
|
||||
@@ -86,8 +85,12 @@ class LessonActivity {
|
||||
required this.listening,
|
||||
required this.listeningQuestion,
|
||||
required this.answers,
|
||||
this.listeningItemIds = const [],
|
||||
required this.speaking,
|
||||
this.speakingTip = '',
|
||||
this.speakingChunks = const [],
|
||||
this.speakingItemIds = const [],
|
||||
this.speakingExamples = const [],
|
||||
required this.reading,
|
||||
required this.readingQuestion,
|
||||
required this.readingAnswer,
|
||||
@@ -100,14 +103,17 @@ class LessonActivity {
|
||||
required this.independentHelp,
|
||||
});
|
||||
|
||||
|
||||
final String listening;
|
||||
final String listeningQuestion;
|
||||
final List<String> answers;
|
||||
final List<String> listeningItemIds;
|
||||
final String speaking;
|
||||
|
||||
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
||||
final String speakingTip;
|
||||
final List<String> speakingChunks;
|
||||
final List<String> speakingItemIds;
|
||||
final List<String> speakingExamples;
|
||||
final String reading;
|
||||
final String readingQuestion;
|
||||
final String readingAnswer;
|
||||
@@ -135,7 +141,6 @@ class LessonDialogue {
|
||||
this.taskLabels = const [],
|
||||
});
|
||||
|
||||
|
||||
final String goal;
|
||||
final List<String> prompts;
|
||||
final List<String> hints;
|
||||
@@ -165,7 +170,6 @@ class DialogueScene {
|
||||
this.unlockAfterLessonId,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String summary;
|
||||
|
||||
@@ -214,8 +214,12 @@ class PackSegment {
|
||||
required this.listeningText,
|
||||
required this.listeningQuestion,
|
||||
required this.listeningOptions,
|
||||
this.listeningItemIds = const [],
|
||||
required this.speakingText,
|
||||
required this.speakingTip,
|
||||
this.speakingChunks = const [],
|
||||
this.speakingItemIds = const [],
|
||||
this.speakingExamples = const [],
|
||||
required this.readingText,
|
||||
required this.readingQuestion,
|
||||
required this.readingOptions,
|
||||
@@ -241,9 +245,13 @@ class PackSegment {
|
||||
final String listeningText;
|
||||
final String listeningQuestion;
|
||||
final List<String> listeningOptions;
|
||||
final List<String> listeningItemIds;
|
||||
|
||||
final String speakingText;
|
||||
final String speakingTip;
|
||||
final List<String> speakingChunks;
|
||||
final List<String> speakingItemIds;
|
||||
final List<String> speakingExamples;
|
||||
|
||||
final String readingText;
|
||||
final String readingQuestion;
|
||||
@@ -288,8 +296,15 @@ class PackSegment {
|
||||
listeningText: listening['text'] as String? ?? '',
|
||||
listeningQuestion: listening['question'] as String? ?? '',
|
||||
listeningOptions: _stringList(listening['options']),
|
||||
listeningItemIds: _stringList(listening['itemIds']),
|
||||
speakingText: firstSpeaking['text'] as String? ?? '',
|
||||
speakingTip: firstSpeaking['tip'] as String? ?? '',
|
||||
speakingChunks: _stringList(firstSpeaking['chunks']),
|
||||
speakingItemIds: _stringList(firstSpeaking['itemIds']),
|
||||
speakingExamples: [
|
||||
for (final line in speaking.skip(1))
|
||||
if (line['text'] is String) line['text'] as String,
|
||||
],
|
||||
readingText: reading['text'] as String? ?? '',
|
||||
readingQuestion: reading['question'] as String? ?? '',
|
||||
readingOptions: _stringList(reading['options']),
|
||||
|
||||
@@ -225,8 +225,12 @@ class CourseRepository {
|
||||
answers: seg.listeningOptions.isNotEmpty
|
||||
? seg.listeningOptions
|
||||
: const [''],
|
||||
listeningItemIds: seg.listeningItemIds,
|
||||
speaking: seg.speakingText,
|
||||
speakingTip: seg.speakingTip,
|
||||
speakingChunks: seg.speakingChunks,
|
||||
speakingItemIds: seg.speakingItemIds,
|
||||
speakingExamples: seg.speakingExamples,
|
||||
reading: seg.readingText,
|
||||
readingQuestion: seg.readingQuestion,
|
||||
readingAnswer: seg.readingOptions.isNotEmpty
|
||||
|
||||
@@ -84,13 +84,7 @@ DialogueTurnDecision resolveDialogueTurnDecision({
|
||||
: DialogueValidationSource.local;
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: true,
|
||||
evidenceOutcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: aiWasAttempted
|
||||
? EvidenceKind.pending
|
||||
: isFreeScene
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.pending,
|
||||
evidenceOutcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||
validationSource: source,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
|
||||
@@ -52,7 +52,9 @@ class LocalSnapshotStore {
|
||||
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
|
||||
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
|
||||
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
transcript_edited INTEGER NOT NULL DEFAULT 0
|
||||
transcript_edited INTEGER NOT NULL DEFAULT 0,
|
||||
listening_support TEXT, first_try INTEGER, attempt_count INTEGER,
|
||||
used_sentence_build INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await _ensureColumn(
|
||||
@@ -73,6 +75,19 @@ class LocalSnapshotStore {
|
||||
name: 'transcript_edited',
|
||||
definition: 'INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
for (final column in const <String, String>{
|
||||
'listening_support': 'TEXT',
|
||||
'first_try': 'INTEGER',
|
||||
'attempt_count': 'INTEGER',
|
||||
'used_sentence_build': 'INTEGER NOT NULL DEFAULT 0',
|
||||
}.entries) {
|
||||
await _ensureColumn(
|
||||
executor,
|
||||
table: 'attempt_evidence',
|
||||
name: column.key,
|
||||
definition: column.value,
|
||||
);
|
||||
}
|
||||
await executor.runCustom('''
|
||||
CREATE TABLE IF NOT EXISTS mastery_items (
|
||||
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
|
||||
@@ -193,7 +208,8 @@ class LocalSnapshotStore {
|
||||
(await executor.runSelect(
|
||||
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
|
||||
created_at, raw_answer, recording_path, assisted, variant_index,
|
||||
original_transcript, transcript_confirmed, transcript_edited
|
||||
original_transcript, transcript_confirmed, transcript_edited,
|
||||
listening_support, first_try, attempt_count, used_sentence_build
|
||||
FROM attempt_evidence ORDER BY created_at''',
|
||||
const [],
|
||||
))
|
||||
@@ -213,6 +229,12 @@ class LocalSnapshotStore {
|
||||
'originalTranscript': row['original_transcript'],
|
||||
'transcriptConfirmed': row['transcript_confirmed'] == 1,
|
||||
'transcriptEdited': row['transcript_edited'] == 1,
|
||||
'listeningSupport': row['listening_support'],
|
||||
'firstTry': row['first_try'] == null
|
||||
? null
|
||||
: row['first_try'] == 1,
|
||||
'attemptCount': row['attempt_count'],
|
||||
'usedSentenceBuild': row['used_sentence_build'] == 1,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
@@ -333,8 +355,9 @@ class LocalSnapshotStore {
|
||||
'''INSERT INTO attempt_evidence(
|
||||
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
|
||||
raw_answer, recording_path, assisted, variant_index,
|
||||
original_transcript, transcript_confirmed, transcript_edited
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
original_transcript, transcript_confirmed, transcript_edited,
|
||||
listening_support, first_try, attempt_count, used_sentence_build
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
[
|
||||
entry['id'],
|
||||
entry['itemId'],
|
||||
@@ -350,6 +373,14 @@ class LocalSnapshotStore {
|
||||
entry['originalTranscript'],
|
||||
entry['transcriptConfirmed'] == true ? 1 : 0,
|
||||
entry['transcriptEdited'] == true ? 1 : 0,
|
||||
entry['listeningSupport'],
|
||||
entry['firstTry'] == null
|
||||
? null
|
||||
: entry['firstTry'] == true
|
||||
? 1
|
||||
: 0,
|
||||
entry['attemptCount'],
|
||||
entry['usedSentenceBuild'] == true ? 1 : 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -435,6 +466,12 @@ class LocalSnapshotStore {
|
||||
'completedSegmentIds',
|
||||
'activeSegmentIndexes',
|
||||
'lessonListeningComplete',
|
||||
'listeningAudioPlayed',
|
||||
'listeningAudioUnavailable',
|
||||
'listeningSupport',
|
||||
'listeningAttempts',
|
||||
'sentenceBuildUsed',
|
||||
'sentenceBuildIndex',
|
||||
'lessonSpeakingComplete',
|
||||
'lessonReadingComplete',
|
||||
'lessonWritingComplete',
|
||||
|
||||
@@ -92,6 +92,8 @@ enum EvidenceKind {
|
||||
pending,
|
||||
}
|
||||
|
||||
enum ListeningSupport { none, slow, transcript }
|
||||
|
||||
/// Local dialogue matching is intentionally tri-state. A natural English
|
||||
/// reply that does not match a strict taught-language rule is unknown, not
|
||||
/// automatically wrong.
|
||||
@@ -160,6 +162,10 @@ class AttemptEvidence {
|
||||
this.originalTranscript,
|
||||
this.transcriptConfirmed = false,
|
||||
this.transcriptEdited = false,
|
||||
this.listeningSupport,
|
||||
this.firstTry,
|
||||
this.attemptCount,
|
||||
this.usedSentenceBuild = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -182,6 +188,10 @@ class AttemptEvidence {
|
||||
final String? originalTranscript;
|
||||
final bool transcriptConfirmed;
|
||||
final bool transcriptEdited;
|
||||
final ListeningSupport? listeningSupport;
|
||||
final bool? firstTry;
|
||||
final int? attemptCount;
|
||||
final bool usedSentenceBuild;
|
||||
}
|
||||
|
||||
enum AiProviderType { mock, gemini, openAi, compatible }
|
||||
@@ -212,8 +222,9 @@ class DialogueAiResponse {
|
||||
final String? feedback;
|
||||
}
|
||||
|
||||
/// A bounded suggestion only. It never changes completion, mastery, or a
|
||||
/// stage result; those decisions remain on-device and evidence-based.
|
||||
/// A bounded evaluation input. Lesson free-response evidence requires both
|
||||
/// local task matching and an accepted verdict; mastery and stage decisions
|
||||
/// still remain on-device and derive from recorded attempts.
|
||||
class WritingAiFeedback {
|
||||
const WritingAiFeedback({
|
||||
required this.verdict,
|
||||
|
||||
@@ -77,7 +77,7 @@ class VoiceService {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> speak(String text, {bool slow = false}) async {
|
||||
Future<bool> speak(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await stopRecordingPlayback();
|
||||
await _initTts();
|
||||
@@ -95,8 +95,11 @@ class VoiceService {
|
||||
} catch (_) {}
|
||||
}
|
||||
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
||||
await _tts.speak(text);
|
||||
} catch (_) {}
|
||||
final result = await _tts.speak(text);
|
||||
return result == 1 || result == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopSpeaking() async {
|
||||
|
||||
Reference in New Issue
Block a user