fluffychat/lib/pangea/analytics_practice/analytics_practice_session_repo.dart

296 lines
9.6 KiB
Dart

import 'dart:math';
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_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.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/lemmas/lemma.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.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 {}
class AnalyticsPracticeSessionRepo {
static Future<AnalyticsPracticeSessionModel> get(
ConstructTypeEnum type,
) async {
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
false) {
throw UnsubscribedException();
}
final r = Random();
final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type);
final types = List.generate(
AnalyticsPracticeConstants.practiceGroupSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final List<AnalyticsActivityTarget> targets = [];
if (type == ConstructTypeEnum.vocab) {
final constructs = await _fetchVocab();
final targetCount = min(constructs.length, types.length);
targets.addAll([
for (var i = 0; i < targetCount; i++)
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
),
),
]);
} else {
final errorTargets = await _fetchErrors();
targets.addAll(errorTargets);
if (targets.length < AnalyticsPracticeConstants.practiceGroupSize) {
final morphs = await _fetchMorphs();
final remainingCount =
AnalyticsPracticeConstants.practiceGroupSize - targets.length;
final morphEntries = morphs.entries.take(remainingCount);
for (final entry in morphEntries) {
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [entry.key],
activityType: ActivityTypeEnum.grammarCategory,
morphFeature: entry.value,
),
),
);
}
targets.shuffle();
}
}
if (targets.isEmpty) {
throw InsufficientDataException();
}
final session = AnalyticsPracticeSessionModel(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
practiceTargets: targets,
);
return session;
}
static Future<List<ConstructIdentifier>> _fetchVocab() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
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) {
break;
}
}
return targets;
}
static Future<Map<PangeaToken, MorphFeaturesEnum>> _fetchMorphs() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.morph)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
final targets = <PangeaToken, MorphFeaturesEnum>{};
final Set<String> seenForms = {};
for (final entry in constructs) {
if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) {
break;
}
final feature = MorphFeaturesEnumExtension.fromString(entry.id.category);
if (feature == MorphFeaturesEnum.Unknown) {
continue;
}
for (final use in entry.cappedUses) {
if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) {
break;
}
if (use.lemma.isEmpty) continue;
final form = use.form;
if (seenForms.contains(form) || form == 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[token] = feature;
break;
}
}
return targets;
}
static Future<List<AnalyticsActivityTarget>> _fetchErrors() async {
final uses = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getUses(count: 100, type: ConstructUseTypeEnum.ga);
final client = MatrixState.pangeaController.matrixState.client;
final Map<String, PangeaMessageEvent?> idsToEvents = {};
for (final use in uses) {
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;
if (igcMatch?.isGrammarMatch != true ||
igcMatch?.match.bestChoice == null) {
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
if (choiceTokens.isEmpty) 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;
}
}