feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动

阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 16:38:23 +09:00
co-authored by Claude Opus 5
parent 0c16394b5a
commit 61abc69037
24 changed files with 2896 additions and 665 deletions
+131 -34
View File
@@ -45,6 +45,10 @@ class AppState extends ChangeNotifier {
final List<AssessmentRecord> assessments = [];
AssessmentDraft? assessmentDraft;
DialogueDraft? dialogueDraft;
/// The free "初次见面" scene keeps its own slot, so resuming a scene never
/// overwrites an unfinished lesson dialogue.
DialogueDraft? sceneDialogueDraft;
LessonStep lessonStep = LessonStep.preview;
int previewIndex = 0;
bool lessonListeningComplete = false;
@@ -80,6 +84,7 @@ class AppState extends ChangeNotifier {
String? adaptiveLessonDraftRecordingPath;
final Set<String> reportedAdaptiveLessonIds = {};
final Map<String, TemporaryLexiconEntry> temporaryLexicon = {};
final Map<String, SentenceAnalysisResult> sentenceAnalyses = {};
final List<ReviewItem> reviewQueue = [];
final List<AttemptEvidence> attemptEvidence = [];
@@ -255,6 +260,17 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
void saveSceneDialogueDraft(DialogueDraft draft) {
sceneDialogueDraft = draft;
notifyListeners();
}
void clearSceneDialogueDraft() {
if (sceneDialogueDraft == null) return;
sceneDialogueDraft = null;
notifyListeners();
}
int get usableMasteryCount => mastery.values
.where(
(item) =>
@@ -407,6 +423,24 @@ class AppState extends ChangeNotifier {
),
);
}
final savedSentenceAnalyses = data['sentenceAnalyses'] as List<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
return MapEntry(
_normalizeLexiconQuery(query),
SentenceAnalysisResult.fromJson(payload),
);
})
.where((entry) => entry.key.isNotEmpty && entry.value.translation.isNotEmpty),
);
}
aiProvider = _enumValue(
AiProviderType.values,
data['aiProvider'] as String?,
@@ -580,26 +614,51 @@ class AppState extends ChangeNotifier {
.map((key, value) => MapEntry(key, value == true)),
);
}
final savedDialogue = data['dialogueDraft'] as Map<String, dynamic>?;
if (savedDialogue != null) {
dialogueDraft = DialogueDraft(
lessonId: savedDialogue['lessonId'] as String,
stage: savedDialogue['stage'] as int? ?? 0,
usedHelp: savedDialogue['usedHelp'] as bool? ?? false,
turns: (savedDialogue['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
if (saved == null || saved['lessonId'] is! String) return null;
return DialogueDraft(
lessonId: saved['lessonId'] as String,
stage: saved['stage'] as int? ?? 0,
usedHelp: saved['usedHelp'] as bool? ?? false,
turns: (saved['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
draft == null
? null
: {
'lessonId': draft.lessonId,
'stage': draft.stage,
'usedHelp': draft.usedHelp,
'turns': draft.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
};
T _enumValue<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
@@ -663,6 +722,17 @@ class AppState extends ChangeNotifier {
},
)
.toList(),
'sentenceAnalyses': sentenceAnalyses.entries
.map(
(entry) => {
'query': entry.key,
'payload': entry.value.toJson(),
'provider': entry.value.provider,
'model': entry.value.model,
'createdAt': entry.value.createdAt.toIso8601String(),
},
)
.toList(),
'aiProvider': aiProvider.name,
'lessonStep': lessonStep.name,
'previewIndex': previewIndex,
@@ -754,22 +824,8 @@ class AppState extends ChangeNotifier {
'taskIndex': assessmentDraft!.taskIndex,
'results': assessmentDraft!.results,
},
'dialogueDraft': dialogueDraft == null
? null
: {
'lessonId': dialogueDraft!.lessonId,
'stage': dialogueDraft!.stage,
'usedHelp': dialogueDraft!.usedHelp,
'turns': dialogueDraft!.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
},
'dialogueDraft': _dialogueDraftToJson(dialogueDraft),
'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft),
}),
).catchError((_) => false).whenComplete(() {
_writing = false;
@@ -815,6 +871,46 @@ class AppState extends ChangeNotifier {
}
}
SentenceAnalysisResult? sentenceAnalysisFor(String query) =>
sentenceAnalyses[_normalizeLexiconQuery(query)];
void cacheSentenceAnalysis(SentenceAnalysisResult result) {
final key = _normalizeLexiconQuery(result.originalText);
if (key.isEmpty || result.translation.trim().isEmpty) return;
sentenceAnalyses[key] = result;
notifyListeners();
}
void removeSentenceAnalysis(String query) {
if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) {
notifyListeners();
}
}
void addPhraseToReview({
required String phrase,
required String meaning,
String? ipa,
String? usageNote,
required String contextSentence,
}) {
final cleanPhrase = phrase.trim();
if (cleanPhrase.isEmpty) return;
final id = 'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}';
final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty
? '$meaning ($usageNote)'
: meaning;
final item = VocabularyItem(
id: id,
word: cleanPhrase,
meaning: fullMeaning,
example: contextSentence.trim(),
exampleMeaning: meaning,
ipa: ipa,
);
addSavedWord(item);
}
void finishOnboarding() {
onboardingComplete = true;
notifyListeners();
@@ -1631,6 +1727,7 @@ class AppState extends ChangeNotifier {
assessments.clear();
assessmentDraft = null;
dialogueDraft = null;
sceneDialogueDraft = null;
lessonStep = LessonStep.preview;
previewIndex = 0;
lessonListeningComplete = false;