5721 practice example message improvements (#5748)
* organized analytics practice session repo * refactor target generation for grammar error activities * improve grammar error target generation * more improvements to target generation
This commit is contained in:
parent
5c645904d0
commit
f7539c184f
20 changed files with 629 additions and 653 deletions
|
|
@ -438,6 +438,8 @@ class HtmlMessage extends StatelessWidget {
|
|||
pangeaMessageEvent != null && !pangeaMessageEvent!.ownMessage
|
||||
? TokensUtil.getNewTokensByEvent(pangeaMessageEvent!)
|
||||
: [];
|
||||
|
||||
final practiceMode = overlayController?.practiceController.practiceMode;
|
||||
// Pangea#
|
||||
|
||||
switch (node.localName) {
|
||||
|
|
@ -559,10 +561,7 @@ class HtmlMessage extends StatelessWidget {
|
|||
curve: Curves.easeOut,
|
||||
child: SizedBox(
|
||||
height:
|
||||
overlayController!
|
||||
.practiceController
|
||||
.practiceMode !=
|
||||
MessagePracticeMode.noneSelected
|
||||
practiceMode != MessagePracticeMode.noneSelected
|
||||
? 4.0
|
||||
: 0.0,
|
||||
width: tokenWidth,
|
||||
|
|
@ -1003,9 +1002,7 @@ class HtmlMessage extends StatelessWidget {
|
|||
),
|
||||
curve: Curves.easeOut,
|
||||
child: SizedBox(
|
||||
height:
|
||||
overlayController!.practiceController.practiceMode !=
|
||||
MessagePracticeMode.noneSelected
|
||||
height: practiceMode != MessagePracticeMode.noneSelected
|
||||
? 4.0
|
||||
: 0.0,
|
||||
width: 0,
|
||||
|
|
|
|||
14
lib/pangea/analytics_misc/construct_practice_extension.dart
Normal file
14
lib/pangea/analytics_misc/construct_practice_extension.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
|
||||
extension ConstructPracticeExtension on List<ConstructUses> {
|
||||
List<ConstructUses> practiceSort(ActivityTypeEnum type) {
|
||||
final sorted = List<ConstructUses>.from(this);
|
||||
sorted.sort((a, b) {
|
||||
final scoreA = a.practiceScore(activityType: type);
|
||||
final scoreB = b.practiceScore(activityType: type);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,11 @@ import 'package:matrix/matrix.dart';
|
|||
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// Internal result class that holds all computed data from building an example message.
|
||||
class _ExampleMessageResult {
|
||||
|
|
@ -37,14 +39,12 @@ class _ExampleMessageResult {
|
|||
|
||||
class ExampleMessageUtil {
|
||||
static Future<List<InlineSpan>?> getExampleMessage(
|
||||
ConstructUses construct,
|
||||
Client client, {
|
||||
ConstructUses construct, {
|
||||
String? form,
|
||||
bool noBold = false,
|
||||
}) async {
|
||||
final result = await _getExampleMessageResult(
|
||||
construct,
|
||||
client,
|
||||
form: form,
|
||||
noBold: noBold,
|
||||
);
|
||||
|
|
@ -52,14 +52,12 @@ class ExampleMessageUtil {
|
|||
}
|
||||
|
||||
static Future<AudioExampleMessage?> getAudioExampleMessage(
|
||||
ConstructUses construct,
|
||||
Client client, {
|
||||
ConstructUses construct, {
|
||||
String? form,
|
||||
bool noBold = false,
|
||||
}) async {
|
||||
final result = await _getExampleMessageResult(
|
||||
construct,
|
||||
client,
|
||||
form: form,
|
||||
noBold: noBold,
|
||||
);
|
||||
|
|
@ -87,14 +85,16 @@ class ExampleMessageUtil {
|
|||
}
|
||||
|
||||
static Future<_ExampleMessageResult?> _getExampleMessageResult(
|
||||
ConstructUses construct,
|
||||
Client client, {
|
||||
ConstructUses construct, {
|
||||
String? form,
|
||||
bool noBold = false,
|
||||
}) async {
|
||||
for (final use in construct.cappedUses) {
|
||||
if (form != null && use.form != form) continue;
|
||||
final uses = List<OneConstructUse>.from(construct.cappedUses);
|
||||
uses.shuffle(); // Shuffle to get a random example message each time
|
||||
|
||||
for (final use in uses) {
|
||||
if (form != null && use.form != form) continue;
|
||||
final client = MatrixState.pangeaController.matrixState.client;
|
||||
final event = await client.getEventByConstructUse(use);
|
||||
if (event == null) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ class AnalyticsPracticeConstants {
|
|||
static const int timeForBonus = 60;
|
||||
static const int practiceGroupSize = 10;
|
||||
static const int errorBufferSize = 5;
|
||||
static int get targetsToGenerate => practiceGroupSize + errorBufferSize;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -636,11 +636,11 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
|
|||
activity,
|
||||
);
|
||||
|
||||
final use = activity.constructUse(choiceContent);
|
||||
_sessionLoader.value!.submitAnswer(use);
|
||||
final uses = activity.constructUses(choiceContent);
|
||||
_sessionLoader.value!.submitAnswer(uses);
|
||||
await _analyticsService.updateService.addAnalytics(
|
||||
choiceTargetId(choiceContent),
|
||||
[use],
|
||||
uses,
|
||||
_l2!.langCodeShort,
|
||||
);
|
||||
|
||||
|
|
@ -696,7 +696,6 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
|
|||
|
||||
return ExampleMessageUtil.getExampleMessage(
|
||||
await _analyticsService.getConstructUse(construct, _l2!.langCodeShort),
|
||||
Matrix.of(context).client,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
|
|
@ -128,11 +128,9 @@ class AnalyticsPracticeSessionModel {
|
|||
}) : state = state ?? const AnalyticsPracticeSessionState();
|
||||
|
||||
// Maximum activities to attempt (including skips)
|
||||
int get _maxAttempts =>
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)
|
||||
.clamp(0, practiceTargets.length)
|
||||
.toInt();
|
||||
int get _maxAttempts => AnalyticsPracticeConstants.targetsToGenerate
|
||||
.clamp(0, practiceTargets.length)
|
||||
.toInt();
|
||||
|
||||
int get _completionGoal => AnalyticsPracticeConstants.practiceGroupSize.clamp(
|
||||
0,
|
||||
|
|
@ -186,8 +184,8 @@ class AnalyticsPracticeSessionModel {
|
|||
void incrementSkippedActivities() =>
|
||||
state = state.copyWith(skippedActivities: state.skippedActivities + 1);
|
||||
|
||||
void submitAnswer(OneConstructUse use) =>
|
||||
state = state.copyWith(completedUses: [...state.completedUses, use]);
|
||||
void submitAnswer(List<OneConstructUse> uses) =>
|
||||
state = state.copyWith(completedUses: [...state.completedUses, ...uses]);
|
||||
|
||||
factory AnalyticsPracticeSessionModel.fromJson(Map<String, dynamic> json) {
|
||||
return AnalyticsPracticeSessionModel(
|
||||
|
|
|
|||
|
|
@ -1,26 +1,13 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/grammar_error_target_generator.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/grammar_match_target_generator.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/vocab_audio_target_generator.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_target_generator.dart';
|
||||
import 'package:fluffychat/pangea/common/network/requests.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
|
||||
import 'package:fluffychat/pangea/languages/language_constants.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class InsufficientDataException implements Exception {}
|
||||
|
|
@ -36,73 +23,48 @@ class AnalyticsPracticeSessionRepo {
|
|||
}
|
||||
|
||||
final List<AnalyticsActivityTarget> targets = [];
|
||||
final analytics =
|
||||
MatrixState.pangeaController.matrixState.analyticsDataService;
|
||||
|
||||
final vocabConstructs = await analytics
|
||||
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
if (type == ConstructTypeEnum.vocab) {
|
||||
const totalNeeded =
|
||||
AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize;
|
||||
final totalNeeded = AnalyticsPracticeConstants.targetsToGenerate;
|
||||
final halfNeeded = (totalNeeded / 2).ceil();
|
||||
|
||||
// Fetch audio constructs (with example messages)
|
||||
final audioMap = await _fetchAudio(language);
|
||||
final audioCount = min(audioMap.length, halfNeeded);
|
||||
final audioTargets = await VocabAudioTargetGenerator.get(vocabConstructs);
|
||||
final audioCount = min(audioTargets.length, halfNeeded);
|
||||
|
||||
// Fetch vocab constructs to fill the rest
|
||||
final vocabNeeded = totalNeeded - audioCount;
|
||||
final vocabConstructs = await _fetchVocab(language);
|
||||
final vocabCount = min(vocabConstructs.length, vocabNeeded);
|
||||
final vocabTargets = await VocabMeaningTargetGenerator.get(
|
||||
vocabConstructs,
|
||||
);
|
||||
final vocabCount = min(vocabTargets.length, vocabNeeded);
|
||||
|
||||
for (final entry in audioMap.entries.take(audioCount)) {
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [entry.key.asToken],
|
||||
activityType: ActivityTypeEnum.lemmaAudio,
|
||||
),
|
||||
audioExampleMessage: entry.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (var i = 0; i < vocabCount; i++) {
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [vocabConstructs[i].asToken],
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
targets.shuffle();
|
||||
final audioTargetsToAdd = audioTargets.take(audioCount);
|
||||
final meaningTargetsToAdd = vocabTargets.take(vocabCount);
|
||||
targets.addAll(audioTargetsToAdd);
|
||||
targets.addAll(meaningTargetsToAdd);
|
||||
} else {
|
||||
final errorTargets = await _fetchErrors(language);
|
||||
final errorTargets = await GrammarErrorTargetGenerator.get(
|
||||
vocabConstructs,
|
||||
);
|
||||
targets.addAll(errorTargets);
|
||||
if (targets.length <
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)) {
|
||||
final morphs = await _fetchMorphs(language);
|
||||
|
||||
if (targets.length < AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
final morphConstructs = await analytics
|
||||
.getAggregatedConstructs(ConstructTypeEnum.morph, language)
|
||||
.then((map) => map.values.toList());
|
||||
final morphs = await GrammarMatchTargetGenerator.get(morphConstructs);
|
||||
final remainingCount =
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize) -
|
||||
targets.length;
|
||||
AnalyticsPracticeConstants.targetsToGenerate - targets.length;
|
||||
|
||||
final morphEntries = morphs.take(remainingCount);
|
||||
|
||||
for (final entry in morphEntries) {
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [entry.token],
|
||||
activityType: ActivityTypeEnum.grammarCategory,
|
||||
morphFeature: entry.feature,
|
||||
),
|
||||
exampleMessage: ExampleMessageInfo(
|
||||
exampleMessage: entry.exampleMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
targets.shuffle();
|
||||
targets.addAll(morphEntries);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +72,7 @@ class AnalyticsPracticeSessionRepo {
|
|||
throw InsufficientDataException();
|
||||
}
|
||||
|
||||
targets.shuffle();
|
||||
final session = AnalyticsPracticeSessionModel(
|
||||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
|
|
@ -118,391 +81,4 @@ class AnalyticsPracticeSessionRepo {
|
|||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
static Future<List<ConstructIdentifier>> _fetchVocab(String language) async {
|
||||
final constructs = await MatrixState
|
||||
.pangeaController
|
||||
.matrixState
|
||||
.analyticsDataService
|
||||
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final scoreA = a.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
final scoreB = b.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final Set<String> seemLemmas = {};
|
||||
final targets = <ConstructIdentifier>[];
|
||||
for (final construct in constructs) {
|
||||
if (seemLemmas.contains(construct.lemma)) continue;
|
||||
seemLemmas.add(construct.lemma);
|
||||
targets.add(construct.id);
|
||||
if (targets.length >=
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
static Future<Map<ConstructIdentifier, AudioExampleMessage>> _fetchAudio(
|
||||
String language,
|
||||
) async {
|
||||
final constructs = await MatrixState
|
||||
.pangeaController
|
||||
.matrixState
|
||||
.analyticsDataService
|
||||
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final scoreA = a.practiceScore(activityType: ActivityTypeEnum.lemmaAudio);
|
||||
final scoreB = b.practiceScore(activityType: ActivityTypeEnum.lemmaAudio);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final Set<String> seenLemmas = {};
|
||||
final Set<String> seenEventIds = {};
|
||||
final targets = <ConstructIdentifier, AudioExampleMessage>{};
|
||||
|
||||
for (final construct in constructs) {
|
||||
if (targets.length >=
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (seenLemmas.contains(construct.lemma)) continue;
|
||||
|
||||
// Try to get an audio example message with token data for this lemma
|
||||
final audioExampleMessage =
|
||||
await ExampleMessageUtil.getAudioExampleMessage(
|
||||
await MatrixState.pangeaController.matrixState.analyticsDataService
|
||||
.getConstructUse(construct.id, language),
|
||||
MatrixState.pangeaController.matrixState.client,
|
||||
noBold: true,
|
||||
);
|
||||
|
||||
// Only add to targets if we found an example message AND its eventId hasn't been used
|
||||
if (audioExampleMessage != null) {
|
||||
final eventId = audioExampleMessage.eventId;
|
||||
if (eventId != null && seenEventIds.contains(eventId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenLemmas.add(construct.lemma);
|
||||
if (eventId != null) {
|
||||
seenEventIds.add(eventId);
|
||||
}
|
||||
targets[construct.id] = audioExampleMessage;
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
static Future<List<MorphPracticeTarget>> _fetchMorphs(String language) async {
|
||||
final constructs = await MatrixState
|
||||
.pangeaController
|
||||
.matrixState
|
||||
.analyticsDataService
|
||||
.getAggregatedConstructs(ConstructTypeEnum.morph, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
final morphInfoRequest = MorphInfoRequest(
|
||||
userL1:
|
||||
MatrixState.pangeaController.userController.userL1?.langCode ??
|
||||
LanguageKeys.defaultLanguage,
|
||||
userL2:
|
||||
MatrixState.pangeaController.userController.userL2?.langCode ??
|
||||
LanguageKeys.defaultLanguage,
|
||||
);
|
||||
|
||||
final morphInfoResult = await MorphInfoRepo.get(
|
||||
MatrixState.pangeaController.userController.accessToken,
|
||||
morphInfoRequest,
|
||||
);
|
||||
|
||||
// Build list of features with multiple tags (valid for practice)
|
||||
final List<String> validFeatures = [];
|
||||
if (!morphInfoResult.isError) {
|
||||
final response = morphInfoResult.asValue?.value;
|
||||
if (response != null) {
|
||||
for (final feature in response.features) {
|
||||
if (feature.tags.length > 1) {
|
||||
validFeatures.add(feature.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final scoreA = a.practiceScore(
|
||||
activityType: ActivityTypeEnum.grammarCategory,
|
||||
);
|
||||
final scoreB = b.practiceScore(
|
||||
activityType: ActivityTypeEnum.grammarCategory,
|
||||
);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final targets = <MorphPracticeTarget>[];
|
||||
final Set<String> seenForms = {};
|
||||
|
||||
for (final entry in constructs) {
|
||||
if (targets.length >=
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)) {
|
||||
break;
|
||||
}
|
||||
|
||||
final feature = MorphFeaturesEnumExtension.fromString(entry.id.category);
|
||||
|
||||
// Only include features that are in the valid list (have multiple tags)
|
||||
if (feature == MorphFeaturesEnum.Unknown ||
|
||||
(validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<InlineSpan>? exampleMessage;
|
||||
for (final use in entry.cappedUses) {
|
||||
if (targets.length >=
|
||||
(AnalyticsPracticeConstants.practiceGroupSize +
|
||||
AnalyticsPracticeConstants.errorBufferSize)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (use.lemma.isEmpty) continue;
|
||||
final form = use.form;
|
||||
if (seenForms.contains(form) || form == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exampleMessage = await ExampleMessageUtil.getExampleMessage(
|
||||
await MatrixState.pangeaController.matrixState.analyticsDataService
|
||||
.getConstructUse(entry.id, language),
|
||||
MatrixState.pangeaController.matrixState.client,
|
||||
form: form,
|
||||
);
|
||||
|
||||
if (exampleMessage == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenForms.add(form);
|
||||
final token = PangeaToken(
|
||||
lemma: Lemma(text: form, saveVocab: true, form: form),
|
||||
text: PangeaTokenText.fromString(form),
|
||||
pos: 'other',
|
||||
morph: {feature: use.lemma},
|
||||
);
|
||||
targets.add(
|
||||
MorphPracticeTarget(
|
||||
feature: feature,
|
||||
token: token,
|
||||
exampleMessage: exampleMessage,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> _fetchErrors(
|
||||
String language,
|
||||
) async {
|
||||
final allRecentUses = await MatrixState
|
||||
.pangeaController
|
||||
.matrixState
|
||||
.analyticsDataService
|
||||
.getUses(
|
||||
language,
|
||||
count: 300,
|
||||
filterCapped: false,
|
||||
types: [
|
||||
ConstructUseTypeEnum.ga,
|
||||
ConstructUseTypeEnum.corGE,
|
||||
ConstructUseTypeEnum.incGE,
|
||||
],
|
||||
);
|
||||
|
||||
// Filter for grammar error uses
|
||||
final grammarErrorUses = allRecentUses
|
||||
.where((use) => use.useType == ConstructUseTypeEnum.ga)
|
||||
.toList();
|
||||
|
||||
// Create list of recently practiced constructs (last 24 hours)
|
||||
final cutoffTime = DateTime.now().subtract(const Duration(hours: 24));
|
||||
final recentlyPracticedConstructs = allRecentUses
|
||||
.where(
|
||||
(use) =>
|
||||
use.metadata.timeStamp.isAfter(cutoffTime) &&
|
||||
(use.useType == ConstructUseTypeEnum.corGE ||
|
||||
use.useType == ConstructUseTypeEnum.incGE),
|
||||
)
|
||||
.map((use) => use.identifier)
|
||||
.toSet();
|
||||
|
||||
final client = MatrixState.pangeaController.matrixState.client;
|
||||
final Map<String, PangeaMessageEvent?> idsToEvents = {};
|
||||
|
||||
for (final use in grammarErrorUses) {
|
||||
final eventID = use.metadata.eventId;
|
||||
if (eventID == null || idsToEvents.containsKey(eventID)) continue;
|
||||
|
||||
final roomID = use.metadata.roomId;
|
||||
if (roomID == null) {
|
||||
idsToEvents[eventID] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
final room = client.getRoomById(roomID);
|
||||
final event = await room?.getEventById(eventID);
|
||||
if (event == null || event.redacted) {
|
||||
idsToEvents[eventID] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
final timeline = await room!.getTimeline();
|
||||
idsToEvents[eventID] = PangeaMessageEvent(
|
||||
event: event,
|
||||
timeline: timeline,
|
||||
ownMessage: event.senderId == client.userID,
|
||||
);
|
||||
}
|
||||
|
||||
final l2Code =
|
||||
MatrixState.pangeaController.userController.userL2!.langCodeShort;
|
||||
|
||||
final events = idsToEvents.values.whereType<PangeaMessageEvent>().toList();
|
||||
final eventsWithContent = events.where((e) {
|
||||
final originalSent = e.originalSent;
|
||||
final choreo = originalSent?.choreo;
|
||||
final tokens = originalSent?.tokens;
|
||||
return originalSent?.langCode.split("-").first == l2Code &&
|
||||
choreo != null &&
|
||||
tokens != null &&
|
||||
tokens.isNotEmpty &&
|
||||
choreo.choreoSteps.any(
|
||||
(step) =>
|
||||
step.acceptedOrIgnoredMatch?.isGrammarMatch == true &&
|
||||
step.acceptedOrIgnoredMatch?.match.bestChoice != null,
|
||||
);
|
||||
});
|
||||
|
||||
final targets = <AnalyticsActivityTarget>[];
|
||||
for (final event in eventsWithContent) {
|
||||
final originalSent = event.originalSent!;
|
||||
final choreo = originalSent.choreo!;
|
||||
final tokens = originalSent.tokens!;
|
||||
|
||||
for (int i = 0; i < choreo.choreoSteps.length; i++) {
|
||||
final step = choreo.choreoSteps[i];
|
||||
final igcMatch = step.acceptedOrIgnoredMatch;
|
||||
final stepText = choreo.stepText(stepIndex: i - 1);
|
||||
if (igcMatch?.isGrammarMatch != true ||
|
||||
igcMatch?.match.bestChoice == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (igcMatch!.match.offset == 0 &&
|
||||
igcMatch.match.length >= stepText.trim().characters.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (igcMatch.match.isNormalizationError()) {
|
||||
// Skip normalization errors
|
||||
continue;
|
||||
}
|
||||
|
||||
final choices = igcMatch.match.choices!.map((c) => c.value).toList();
|
||||
final choiceTokens = tokens
|
||||
.where(
|
||||
(token) =>
|
||||
token.lemma.saveVocab &&
|
||||
choices.any((choice) => choice.contains(token.text.content)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Skip if no valid tokens found for this grammar error, or only one answer
|
||||
if (choiceTokens.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final firstToken = choiceTokens.first;
|
||||
final tokenIdentifier = ConstructIdentifier(
|
||||
lemma: firstToken.lemma.text,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
category: firstToken.pos,
|
||||
);
|
||||
|
||||
final hasRecentPractice = recentlyPracticedConstructs.contains(
|
||||
tokenIdentifier,
|
||||
);
|
||||
|
||||
if (hasRecentPractice) continue;
|
||||
|
||||
String? translation;
|
||||
try {
|
||||
translation = await event.requestRespresentationByL1();
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'context': 'AnalyticsPracticeSessionRepo._fetchErrors',
|
||||
'message': 'Failed to fetch translation for analytics practice',
|
||||
'event_id': event.eventId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (translation == null) continue;
|
||||
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: choiceTokens,
|
||||
activityType: ActivityTypeEnum.grammarError,
|
||||
morphFeature: null,
|
||||
),
|
||||
grammarErrorInfo: GrammarErrorRequestInfo(
|
||||
choreo: choreo,
|
||||
stepIndex: i,
|
||||
eventID: event.eventId,
|
||||
translation: translation,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
||||
class MorphPracticeTarget {
|
||||
final PangeaToken token;
|
||||
final MorphFeaturesEnum feature;
|
||||
final List<InlineSpan> exampleMessage;
|
||||
|
||||
MorphPracticeTarget({
|
||||
required this.token,
|
||||
required this.feature,
|
||||
required this.exampleMessage,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ class GrammarErrorPracticeGenerator {
|
|||
choices.add(errorSpan);
|
||||
}
|
||||
|
||||
if (igcMatch.offset + igcMatch.length > stepText.characters.length) {
|
||||
// Sometimes choreo records turn out weird when users edit the message
|
||||
// mid-IGC. If the offsets / lengths don't make sense, skip this target.
|
||||
throw Exception(
|
||||
"IGC match offset and length exceed step text length. Step text: '$stepText', match offset: ${igcMatch.offset}, match length: ${igcMatch.length}",
|
||||
);
|
||||
}
|
||||
|
||||
choices.shuffle();
|
||||
return MessageActivityResponse(
|
||||
activity: GrammarErrorPracticeActivityModel(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class GrammarErrorTargetGenerator {
|
||||
static ActivityTypeEnum activityType = ActivityTypeEnum.grammarError;
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> get(
|
||||
List<ConstructUses> constructs,
|
||||
) async {
|
||||
final client = MatrixState.pangeaController.matrixState.client;
|
||||
final Map<String, PangeaMessageEvent?> seenEventIDs = {};
|
||||
final cutoffTime = DateTime.now().subtract(const Duration(hours: 24));
|
||||
|
||||
final targets = <AnalyticsActivityTarget>[];
|
||||
for (final construct in constructs) {
|
||||
final lastPracticeUse = construct.lastUseByTypes(
|
||||
activityType.associatedUseTypes,
|
||||
);
|
||||
|
||||
if (lastPracticeUse != null && lastPracticeUse.isAfter(cutoffTime)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final errorUses = construct.cappedUses.where(
|
||||
(u) => u.useType == ConstructUseTypeEnum.ga,
|
||||
);
|
||||
if (errorUses.isEmpty) continue;
|
||||
|
||||
for (final use in errorUses) {
|
||||
final eventID = use.metadata.eventId;
|
||||
if (eventID == null) continue;
|
||||
if (seenEventIDs.containsKey(eventID) &&
|
||||
seenEventIDs[eventID] == null) {
|
||||
continue; // Already checked this event and it had no valid grammar error match
|
||||
}
|
||||
|
||||
final event =
|
||||
seenEventIDs[eventID] ?? await client.getEventByConstructUse(use);
|
||||
|
||||
seenEventIDs[eventID] = event;
|
||||
}
|
||||
}
|
||||
|
||||
final events = seenEventIDs.values.whereType<PangeaMessageEvent>();
|
||||
for (final event in events) {
|
||||
final eventTargets = await _getTargetFromEvent(event);
|
||||
targets.addAll(eventTargets);
|
||||
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> _getTargetFromEvent(
|
||||
PangeaMessageEvent event,
|
||||
) async {
|
||||
final List<AnalyticsActivityTarget> targets = [];
|
||||
final l2Code =
|
||||
MatrixState.pangeaController.userController.userL2!.langCodeShort;
|
||||
final originalSent = event.originalSent;
|
||||
if (originalSent?.langCode.split("-").first != l2Code) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
final choreo = originalSent?.choreo;
|
||||
if (choreo == null ||
|
||||
!choreo.choreoSteps.any(
|
||||
(step) =>
|
||||
step.acceptedOrIgnoredMatch?.isGrammarMatch == true &&
|
||||
step.acceptedOrIgnoredMatch?.match.bestChoice != null,
|
||||
)) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
final tokens = originalSent?.tokens;
|
||||
if (tokens == null || tokens.isEmpty) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
String? translation;
|
||||
for (int i = 0; i < choreo.choreoSteps.length; i++) {
|
||||
final step = choreo.choreoSteps[i];
|
||||
final igcMatch = step.acceptedOrIgnoredMatch;
|
||||
if (igcMatch?.isGrammarMatch != true ||
|
||||
igcMatch?.match.bestChoice == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final stepText = choreo.stepText(stepIndex: i - 1);
|
||||
final errorSpan = stepText.characters
|
||||
.skip(igcMatch!.match.offset)
|
||||
.take(igcMatch.match.length)
|
||||
.toString();
|
||||
|
||||
if (igcMatch.match.isNormalizationError(errorSpanOverride: errorSpan)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (igcMatch.match.offset == 0 &&
|
||||
igcMatch.match.length >= stepText.trim().characters.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final choices = igcMatch.match.choices!.map((c) => c.value).toList();
|
||||
final choiceTokens = tokens
|
||||
.where(
|
||||
(token) =>
|
||||
token.lemma.saveVocab &&
|
||||
choices.any((choice) => choice.contains(token.text.content)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Skip if no valid tokens found for this grammar error, or only one answer
|
||||
if (choiceTokens.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
translation ??= await event.requestRespresentationByL1();
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'context': 'AnalyticsPracticeSessionRepo._fetchErrors',
|
||||
'message': 'Failed to fetch translation for analytics practice',
|
||||
'event_id': event.eventId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (translation == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: choiceTokens,
|
||||
activityType: activityType,
|
||||
),
|
||||
grammarErrorInfo: GrammarErrorRequestInfo(
|
||||
choreo: choreo,
|
||||
stepIndex: i,
|
||||
eventID: event.eventId,
|
||||
translation: translation,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_form.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class GrammarMatchTargetGenerator {
|
||||
static ActivityTypeEnum activityType = ActivityTypeEnum.grammarCategory;
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> get(
|
||||
List<ConstructUses> constructs,
|
||||
) async {
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
final sortedConstructs = constructs.practiceSort(activityType);
|
||||
|
||||
final Set<String> seenForms = {};
|
||||
|
||||
final morphInfoResult = await MorphsRepo.get(
|
||||
MatrixState.pangeaController.userController.userL2,
|
||||
);
|
||||
|
||||
// Build list of features with multiple tags (valid for practice)
|
||||
final List<String> validFeatures = morphInfoResult.features
|
||||
.where((f) => f.tags.length > 1)
|
||||
.map((f) => f.feature)
|
||||
.toList();
|
||||
|
||||
final targets = <AnalyticsActivityTarget>[];
|
||||
|
||||
for (final construct in sortedConstructs) {
|
||||
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
break;
|
||||
}
|
||||
|
||||
final feature = MorphFeaturesEnumExtension.fromString(construct.category);
|
||||
|
||||
// Only include features that are in the valid list (have multiple tags)
|
||||
if (feature == MorphFeaturesEnum.Unknown ||
|
||||
(validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<InlineSpan>? exampleMessage;
|
||||
final constructForms = construct.cappedUses
|
||||
.where((u) => u.form != null)
|
||||
.map((u) => ConstructForm(form: u.form!, cId: construct.id))
|
||||
.toSet();
|
||||
|
||||
for (final form in constructForms) {
|
||||
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (seenForms.contains(form.form)) continue;
|
||||
seenForms.add(form.form);
|
||||
|
||||
exampleMessage = await ExampleMessageUtil.getExampleMessage(
|
||||
construct,
|
||||
form: form.form,
|
||||
);
|
||||
if (exampleMessage == null) continue;
|
||||
|
||||
final token = PangeaToken(
|
||||
lemma: Lemma(text: form.form, saveVocab: true, form: form.form),
|
||||
text: PangeaTokenText.fromString(form.form),
|
||||
pos: 'other',
|
||||
morph: {feature: form.cId.lemma},
|
||||
);
|
||||
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [token],
|
||||
activityType: activityType,
|
||||
morphFeature: feature,
|
||||
),
|
||||
exampleMessage: ExampleMessageInfo(exampleMessage: exampleMessage),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
|
||||
class VocabAudioTargetGenerator {
|
||||
static ActivityTypeEnum activityType = ActivityTypeEnum.lemmaAudio;
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> get(
|
||||
List<ConstructUses> constructs,
|
||||
) async {
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
final sortedConstructs = constructs.practiceSort(activityType);
|
||||
|
||||
final Set<String> seenLemmas = {};
|
||||
final Set<String> seenEventIds = {};
|
||||
final targets = <AnalyticsActivityTarget>[];
|
||||
|
||||
for (final construct in sortedConstructs) {
|
||||
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (seenLemmas.contains(construct.lemma)) continue;
|
||||
|
||||
// Try to get an audio example message with token data for this lemma
|
||||
final exampleMessage = await ExampleMessageUtil.getAudioExampleMessage(
|
||||
construct,
|
||||
noBold: true,
|
||||
);
|
||||
|
||||
if (exampleMessage == null) continue;
|
||||
final eventId = exampleMessage.eventId;
|
||||
if (eventId != null && seenEventIds.contains(eventId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenLemmas.add(construct.lemma);
|
||||
if (eventId != null) {
|
||||
seenEventIds.add(eventId);
|
||||
}
|
||||
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [construct.id.asToken],
|
||||
activityType: activityType,
|
||||
),
|
||||
audioExampleMessage: exampleMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
|
||||
class VocabMeaningTargetGenerator {
|
||||
static ActivityTypeEnum activityType = ActivityTypeEnum.lemmaMeaning;
|
||||
|
||||
static Future<List<AnalyticsActivityTarget>> get(
|
||||
List<ConstructUses> constructs,
|
||||
) async {
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
final sortedConstructs = constructs.practiceSort(activityType);
|
||||
|
||||
final Set<String> seenLemmas = {};
|
||||
final targets = <AnalyticsActivityTarget>[];
|
||||
for (final construct in sortedConstructs) {
|
||||
if (seenLemmas.contains(construct.lemma)) continue;
|
||||
seenLemmas.add(construct.lemma);
|
||||
|
||||
if (!construct.cappedUses.any(
|
||||
(u) => u.metadata.eventId != null && u.metadata.roomId != null,
|
||||
)) {
|
||||
// Skip if no uses have eventId + roomId, so example message can be fetched.
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.add(
|
||||
AnalyticsActivityTarget(
|
||||
target: PracticeTarget(
|
||||
tokens: [construct.id.asToken],
|
||||
activityType: activityType,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
|
@ -159,7 +159,7 @@ class SpanData {
|
|||
/// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR
|
||||
/// 2. For backwards compatibility with old data that lacks new types:
|
||||
/// the type is NOT auto-apply AND the normalized strings match.
|
||||
bool isNormalizationError() {
|
||||
bool isNormalizationError({String? errorSpanOverride}) {
|
||||
// New data with explicit auto-apply types
|
||||
if (type.isAutoApply) {
|
||||
return true;
|
||||
|
|
@ -175,7 +175,7 @@ class SpanData {
|
|||
return correctChoice != null &&
|
||||
l2Code != null &&
|
||||
normalizeString(correctChoice, l2Code) ==
|
||||
normalizeString(errorSpan, l2Code);
|
||||
normalizeString(errorSpanOverride ?? errorSpan, l2Code);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ sealed class PracticeActivityModel {
|
|||
: null,
|
||||
);
|
||||
|
||||
bool isCorrect(String choice, PangeaToken token) => false;
|
||||
|
||||
ActivityTypeEnum get activityType {
|
||||
switch (this) {
|
||||
case MorphCategoryPracticeActivityModel():
|
||||
|
|
@ -225,24 +227,32 @@ sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel {
|
|||
required this.multipleChoiceContent,
|
||||
});
|
||||
|
||||
bool isCorrect(String choice) => multipleChoiceContent.isCorrect(choice);
|
||||
@override
|
||||
bool isCorrect(String choice, PangeaToken _) =>
|
||||
multipleChoiceContent.isCorrect(choice);
|
||||
|
||||
OneConstructUse constructUse(String choiceContent) {
|
||||
List<OneConstructUse> constructUses(String choiceContent) {
|
||||
final correct = multipleChoiceContent.isCorrect(choiceContent);
|
||||
final useType = correct
|
||||
? activityType.correctUse
|
||||
: activityType.incorrectUse;
|
||||
final token = tokens.first;
|
||||
|
||||
return OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: token.pos,
|
||||
lemma: token.lemma.text,
|
||||
form: token.lemma.text,
|
||||
xp: useType.pointValue,
|
||||
);
|
||||
return tokens
|
||||
.map(
|
||||
(token) => OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: null,
|
||||
timeStamp: DateTime.now(),
|
||||
),
|
||||
category: token.pos,
|
||||
lemma: token.lemma.text,
|
||||
form: token.lemma.text,
|
||||
xp: useType.pointValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -262,7 +272,8 @@ sealed class MatchPracticeActivityModel extends PracticeActivityModel {
|
|||
required this.matchContent,
|
||||
});
|
||||
|
||||
bool isCorrect(PangeaToken token, String choice) =>
|
||||
@override
|
||||
bool isCorrect(String choice, PangeaToken token) =>
|
||||
matchContent.matchInfo[token.vocabForm]!.contains(choice);
|
||||
|
||||
@override
|
||||
|
|
@ -288,6 +299,31 @@ sealed class MorphPracticeActivityModel
|
|||
String get storageKey =>
|
||||
'${activityType.name}-${tokens.map((e) => e.text.content).join("-")}-${morphFeature.name}';
|
||||
|
||||
@override
|
||||
List<OneConstructUse> constructUses(String choiceContent) {
|
||||
final correct = multipleChoiceContent.isCorrect(choiceContent);
|
||||
final useType = correct
|
||||
? activityType.correctUse
|
||||
: activityType.incorrectUse;
|
||||
|
||||
return tokens
|
||||
.map(
|
||||
(token) => OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.morph,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: null,
|
||||
timeStamp: DateTime.now(),
|
||||
),
|
||||
category: morphFeature.name,
|
||||
lemma: token.getMorphTag(morphFeature)!,
|
||||
form: token.lemma.form,
|
||||
xp: useType.pointValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
|
|
@ -306,26 +342,6 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel {
|
|||
required this.exampleMessageInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
OneConstructUse constructUse(String choiceContent) {
|
||||
final correct = multipleChoiceContent.isCorrect(choiceContent);
|
||||
final token = tokens.first;
|
||||
final useType = correct
|
||||
? activityType.correctUse
|
||||
: activityType.incorrectUse;
|
||||
final tag = token.getMorphTag(morphFeature)!;
|
||||
|
||||
return OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.morph,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: morphFeature.name,
|
||||
lemma: tag,
|
||||
form: token.lemma.form,
|
||||
xp: useType.pointValue,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
|
|
@ -359,7 +375,7 @@ class VocabAudioPracticeActivityModel
|
|||
});
|
||||
|
||||
@override
|
||||
OneConstructUse constructUse(String choiceContent) {
|
||||
List<OneConstructUse> constructUses(String choiceContent) {
|
||||
final correct = multipleChoiceContent.isCorrect(choiceContent);
|
||||
final useType = correct
|
||||
? activityType.correctUse
|
||||
|
|
@ -371,15 +387,17 @@ class VocabAudioPracticeActivityModel
|
|||
orElse: () => tokens.first,
|
||||
);
|
||||
|
||||
return OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: matchingToken.pos,
|
||||
lemma: matchingToken.lemma.text,
|
||||
form: matchingToken.lemma.text,
|
||||
xp: useType.pointValue,
|
||||
);
|
||||
return [
|
||||
OneConstructUse(
|
||||
useType: useType,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: matchingToken.pos,
|
||||
lemma: matchingToken.lemma.text,
|
||||
form: matchingToken.lemma.text,
|
||||
xp: useType.pointValue,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -485,22 +485,14 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
final practice =
|
||||
widget.overlayController.practiceController;
|
||||
|
||||
final instruction =
|
||||
practice.practiceMode.instruction;
|
||||
|
||||
final type =
|
||||
practice.practiceMode.associatedActivityType;
|
||||
final practiceMode = practice.practiceMode;
|
||||
final instruction = practiceMode.instruction;
|
||||
final complete =
|
||||
type != null &&
|
||||
practice.isPracticeSessionDone(type);
|
||||
practice.isCurrentPracticeSessionDone;
|
||||
|
||||
if (instruction != null && !complete) {
|
||||
return InstructionsInlineTooltip(
|
||||
instructionsEnum: widget
|
||||
.overlayController
|
||||
.practiceController
|
||||
.practiceMode
|
||||
.instruction!,
|
||||
instructionsEnum: practiceMode.instruction!,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
animate: false,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,13 +33,55 @@ class PracticeController with ChangeNotifier {
|
|||
|
||||
PracticeActivityModel? _activity;
|
||||
|
||||
MessagePracticeMode practiceMode = MessagePracticeMode.noneSelected;
|
||||
MessagePracticeMode _practiceMode = MessagePracticeMode.noneSelected;
|
||||
|
||||
MorphSelection? selectedMorph;
|
||||
PracticeChoice? selectedChoice;
|
||||
MorphSelection? _selectedMorph;
|
||||
PracticeChoice? _selectedChoice;
|
||||
|
||||
PracticeSelection? practiceSelection;
|
||||
|
||||
MessagePracticeMode get practiceMode => _practiceMode;
|
||||
MorphSelection? get selectedMorph => _selectedMorph;
|
||||
PracticeChoice? get selectedChoice => _selectedChoice;
|
||||
|
||||
PracticeTarget? get currentTarget {
|
||||
final activityType = _practiceMode.associatedActivityType;
|
||||
if (activityType == null) return null;
|
||||
if (activityType == ActivityTypeEnum.morphId) {
|
||||
if (_selectedMorph == null) return null;
|
||||
return practiceSelection?.getMorphTarget(
|
||||
_selectedMorph!.token,
|
||||
_selectedMorph!.morph,
|
||||
);
|
||||
}
|
||||
return practiceSelection?.getTarget(activityType);
|
||||
}
|
||||
|
||||
bool get showChoiceShimmer {
|
||||
if (_activity == null) return false;
|
||||
if (_activity is MorphMatchPracticeActivityModel) {
|
||||
return _selectedMorph != null &&
|
||||
!PracticeRecordController.hasResponse(_activity!.practiceTarget);
|
||||
}
|
||||
|
||||
return _selectedChoice == null &&
|
||||
!PracticeRecordController.hasAnyCorrectChoices(
|
||||
_activity!.practiceTarget,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isTotallyDone =>
|
||||
isPracticeSessionDone(ActivityTypeEnum.emoji) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.wordMeaning) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.morphId);
|
||||
|
||||
bool get isCurrentPracticeSessionDone {
|
||||
final activityType = _practiceMode.associatedActivityType;
|
||||
if (activityType == null) return false;
|
||||
return isPracticeSessionDone(activityType);
|
||||
}
|
||||
|
||||
bool? wasCorrectMatch(PracticeChoice choice) {
|
||||
if (_activity == null) return false;
|
||||
return PracticeRecordController.wasCorrectMatch(
|
||||
|
|
@ -56,12 +98,6 @@ class PracticeController with ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
bool get isTotallyDone =>
|
||||
isPracticeSessionDone(ActivityTypeEnum.emoji) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.wordMeaning) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) &&
|
||||
isPracticeSessionDone(ActivityTypeEnum.morphId);
|
||||
|
||||
bool isPracticeSessionDone(ActivityTypeEnum activityType) =>
|
||||
practiceSelection
|
||||
?.activities(activityType)
|
||||
|
|
@ -70,99 +106,50 @@ class PracticeController with ChangeNotifier {
|
|||
|
||||
bool isPracticeButtonEmpty(PangeaToken token) {
|
||||
final target = practiceTargetForToken(token);
|
||||
|
||||
if (MessagePracticeMode.wordEmoji == practiceMode) {
|
||||
if (token.vocabConstructID.userSetEmoji != null) {
|
||||
return false;
|
||||
}
|
||||
// Keep open even when completed to show emoji
|
||||
return target == null;
|
||||
}
|
||||
|
||||
if (MessagePracticeMode.wordMorph == practiceMode) {
|
||||
// Keep open even when completed to show morph icon
|
||||
return target == null;
|
||||
}
|
||||
|
||||
return target == null ||
|
||||
PracticeRecordController.isCompleteByToken(target, token);
|
||||
}
|
||||
|
||||
bool get showChoiceShimmer {
|
||||
if (_activity == null) return false;
|
||||
if (_activity is MorphMatchPracticeActivityModel) {
|
||||
return selectedMorph != null &&
|
||||
!PracticeRecordController.hasResponse(_activity!.practiceTarget);
|
||||
}
|
||||
|
||||
return selectedChoice == null &&
|
||||
!PracticeRecordController.hasAnyCorrectChoices(
|
||||
_activity!.practiceTarget,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchPracticeSelection() async {
|
||||
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return;
|
||||
practiceSelection = await PracticeSelectionRepo.get(
|
||||
pangeaMessageEvent.eventId,
|
||||
pangeaMessageEvent.messageDisplayLangCode,
|
||||
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<PracticeActivityModel>> fetchActivityModel(
|
||||
PracticeTarget target,
|
||||
) async {
|
||||
final req = MessageActivityRequest(
|
||||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
activityQualityFeedback: null,
|
||||
target: target,
|
||||
);
|
||||
|
||||
final result = await PracticeRepo.getPracticeActivity(
|
||||
req,
|
||||
messageInfo: pangeaMessageEvent.event.content,
|
||||
);
|
||||
if (result.isValue) {
|
||||
_activity = result.result;
|
||||
}
|
||||
|
||||
return result;
|
||||
return switch (_practiceMode) {
|
||||
// Keep open when completed if emoji assigned
|
||||
MessagePracticeMode.wordEmoji =>
|
||||
target == null || token.vocabConstructID.userSetEmoji != null,
|
||||
// Keep open when completed to show morph icon
|
||||
MessagePracticeMode.wordMorph => target == null,
|
||||
_ =>
|
||||
target == null ||
|
||||
PracticeRecordController.isCompleteByToken(target, token),
|
||||
};
|
||||
}
|
||||
|
||||
PracticeTarget? practiceTargetForToken(PangeaToken token) {
|
||||
if (practiceMode.associatedActivityType == null) return null;
|
||||
if (_practiceMode.associatedActivityType == null) return null;
|
||||
return practiceSelection
|
||||
?.activities(practiceMode.associatedActivityType!)
|
||||
?.activities(_practiceMode.associatedActivityType!)
|
||||
.firstWhereOrNull((a) => a.tokens.contains(token));
|
||||
}
|
||||
|
||||
void updateToolbarMode(MessagePracticeMode mode) {
|
||||
selectedChoice = null;
|
||||
practiceMode = mode;
|
||||
if (practiceMode != MessagePracticeMode.wordMorph) {
|
||||
selectedMorph = null;
|
||||
_selectedChoice = null;
|
||||
_practiceMode = mode;
|
||||
if (_practiceMode != MessagePracticeMode.wordMorph) {
|
||||
_selectedMorph = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onChoiceSelect(PracticeChoice? choice, [bool force = false]) {
|
||||
void updatePracticeMorph(MorphSelection newMorph) {
|
||||
_practiceMode = MessagePracticeMode.wordMorph;
|
||||
_selectedMorph = newMorph;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onChoiceSelect(PracticeChoice? choice) {
|
||||
if (_activity == null) return;
|
||||
if (selectedChoice == choice && !force) {
|
||||
selectedChoice = null;
|
||||
if (_selectedChoice == choice) {
|
||||
_selectedChoice = null;
|
||||
} else {
|
||||
selectedChoice = choice;
|
||||
_selectedChoice = choice;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onSelectMorph(MorphSelection newMorph) {
|
||||
practiceMode = MessagePracticeMode.wordMorph;
|
||||
selectedMorph = newMorph;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onMatch(PangeaToken token, PracticeChoice choice) {
|
||||
if (_activity == null) return;
|
||||
final isCorrect = PracticeRecordController.onSelectChoice(
|
||||
|
|
@ -248,4 +235,34 @@ class PracticeController with ChangeNotifier {
|
|||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _fetchPracticeSelection() async {
|
||||
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return;
|
||||
practiceSelection = await PracticeSelectionRepo.get(
|
||||
pangeaMessageEvent.eventId,
|
||||
pangeaMessageEvent.messageDisplayLangCode,
|
||||
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<PracticeActivityModel>> fetchActivityModel(
|
||||
PracticeTarget target,
|
||||
) async {
|
||||
final req = MessageActivityRequest(
|
||||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
activityQualityFeedback: null,
|
||||
target: target,
|
||||
);
|
||||
|
||||
final result = await PracticeRepo.getPracticeActivity(
|
||||
req,
|
||||
messageInfo: pangeaMessageEvent.event.content,
|
||||
);
|
||||
if (result.isValue) {
|
||||
_activity = result.result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ class MatchActivityCard extends StatelessWidget {
|
|||
fontSize = fontSize * 1.5;
|
||||
}
|
||||
|
||||
final selectedChoice = controller.selectedChoice;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
|
|
@ -75,13 +76,13 @@ class MatchActivityCard extends StatelessWidget {
|
|||
) {
|
||||
final bool? wasCorrect = controller.wasCorrectMatch(cf);
|
||||
return ChoiceAnimationWidget(
|
||||
isSelected: controller.selectedChoice == cf,
|
||||
isSelected: selectedChoice == cf,
|
||||
isCorrect: wasCorrect,
|
||||
child: PracticeMatchItem(
|
||||
token: currentActivity.tokens.firstWhereOrNull(
|
||||
(t) => t.vocabConstructID == cf.form.cId,
|
||||
),
|
||||
isSelected: controller.selectedChoice == cf,
|
||||
isSelected: selectedChoice == cf,
|
||||
isCorrect: wasCorrect,
|
||||
constructForm: cf,
|
||||
content: choiceDisplayContent(
|
||||
|
|
|
|||
|
|
@ -93,11 +93,7 @@ class PracticeRecordController {
|
|||
return false;
|
||||
}
|
||||
|
||||
final isCorrect = switch (activity) {
|
||||
MatchPracticeActivityModel() => activity.isCorrect(token, choice),
|
||||
MultipleChoicePracticeActivityModel() => activity.isCorrect(choice),
|
||||
};
|
||||
|
||||
final isCorrect = activity.isCorrect(choice, token);
|
||||
record.addResponse(
|
||||
cId: cId,
|
||||
target: target,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/practice_activity_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.dart';
|
||||
|
|
@ -56,14 +55,15 @@ class ReadingAssistanceInputBarState extends State<ReadingAssistanceInputBar> {
|
|||
final complete = widget.controller.isPracticeSessionDone(
|
||||
m.associatedActivityType!,
|
||||
);
|
||||
|
||||
final practiceMode = widget.controller.practiceMode;
|
||||
return ToolbarButton(
|
||||
mode: m,
|
||||
setMode: () => widget.controller.updateToolbarMode(m),
|
||||
isComplete: complete,
|
||||
isSelected: widget.controller.practiceMode == m,
|
||||
isSelected: practiceMode == m,
|
||||
shimmer:
|
||||
widget.controller.practiceMode ==
|
||||
MessagePracticeMode.noneSelected &&
|
||||
practiceMode == MessagePracticeMode.noneSelected &&
|
||||
!complete,
|
||||
);
|
||||
}),
|
||||
|
|
@ -122,9 +122,9 @@ class _ReadingAssistanceBarContent extends StatelessWidget {
|
|||
if (controller.pangeaMessageEvent.isAudioMessage == true) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final activityType = mode.associatedActivityType;
|
||||
final activityCompleted =
|
||||
activityType != null && controller.isPracticeSessionDone(activityType);
|
||||
|
||||
final target = controller.currentTarget;
|
||||
final activityCompleted = controller.isCurrentPracticeSessionDone;
|
||||
|
||||
switch (mode) {
|
||||
case MessagePracticeMode.noneSelected:
|
||||
|
|
@ -139,7 +139,6 @@ class _ReadingAssistanceBarContent extends StatelessWidget {
|
|||
return const _AllDoneWidget();
|
||||
}
|
||||
|
||||
final target = controller.practiceSelection?.getTarget(activityType!);
|
||||
if (target == null || activityCompleted) {
|
||||
return const Icon(
|
||||
Symbols.fitness_center,
|
||||
|
|
@ -166,15 +165,6 @@ class _ReadingAssistanceBarContent extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
PracticeTarget? target;
|
||||
if (controller.practiceSelection != null &&
|
||||
controller.selectedMorph != null) {
|
||||
target = controller.practiceSelection!.getMorphTarget(
|
||||
controller.selectedMorph!.token,
|
||||
controller.selectedMorph!.morph,
|
||||
);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return const Center(child: Icon(Symbols.fitness_center, size: 60.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class TokenPracticeButton extends StatelessWidget {
|
|||
active: _isSelected,
|
||||
textColor: textColor,
|
||||
width: tokenButtonHeight,
|
||||
onTap: () => controller.onSelectMorph(
|
||||
onTap: () => controller.updatePracticeMorph(
|
||||
MorphSelection(token, _activity!.morphFeature!),
|
||||
),
|
||||
shimmer:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue