fluffychat/lib/pangea/practice_activities/practice_selection_repo.dart
ggurdin 2c176c052d
5053 can get points from lemma with max score (#5078)
* make uses a private field for ConstructUses

* expose capped list of uses in ConstructUses

* filter capped construct uses in getUses
2026-01-06 11:39:49 -05:00

255 lines
7.1 KiB
Dart

import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.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/practice_selection.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _PracticeSelectionCacheEntry {
final PracticeSelection selection;
final DateTime timestamp;
_PracticeSelectionCacheEntry({
required this.selection,
required this.timestamp,
});
bool get isExpired => DateTime.now().difference(timestamp).inDays > 1;
Map<String, dynamic> toJson() => {
'selection': selection.toJson(),
'timestamp': timestamp.toIso8601String(),
};
factory _PracticeSelectionCacheEntry.fromJson(Map<String, dynamic> json) {
return _PracticeSelectionCacheEntry(
selection: PracticeSelection.fromJson(json['selection']),
timestamp: DateTime.parse(json['timestamp']),
);
}
}
class PracticeSelectionRepo {
static final GetStorage _storage = GetStorage('practice_selection_cache');
static Future<PracticeSelection?> get(
String eventId,
String messageLanguage,
List<PangeaToken> tokens,
) async {
final userL2 = MatrixState.pangeaController.userController.userL2;
if (userL2?.langCodeShort != messageLanguage.split("-").first) {
return null;
}
final cached = _getCached(eventId);
if (cached != null) return cached;
final newEntry = await _fetch(
tokens: tokens,
langCode: messageLanguage,
);
_setCached(eventId, newEntry);
return newEntry;
}
static Future<PracticeSelection> _fetch({
required List<PangeaToken> tokens,
required String langCode,
}) async {
if (langCode.split("-")[0] !=
MatrixState.pangeaController.userController.userL2?.langCodeShort) {
return PracticeSelection({});
}
final eligibleTokens = tokens.where((t) => t.lemma.saveVocab).toList();
if (eligibleTokens.isEmpty) {
return PracticeSelection({});
}
final queue = await _fillActivityQueue(eligibleTokens);
final selection = PracticeSelection(queue);
return selection;
}
static PracticeSelection? _getCached(
String eventId,
) {
try {
final keys = List.from(_storage.getKeys());
for (final String key in keys) {
final cacheEntry = _PracticeSelectionCacheEntry.fromJson(
_storage.read(key),
);
if (cacheEntry.isExpired) {
_storage.remove(key);
}
}
} catch (e) {
_storage.erase();
return null;
}
final entry = _storage.read(eventId);
if (entry == null) return null;
try {
return _PracticeSelectionCacheEntry.fromJson(
_storage.read(eventId),
).selection;
} catch (e) {
_storage.remove(eventId);
return null;
}
}
static void _setCached(
String eventId,
PracticeSelection entry,
) {
final cachedEntry = _PracticeSelectionCacheEntry(
selection: entry,
timestamp: DateTime.now(),
);
_storage.write(eventId, cachedEntry.toJson());
}
static Future<Map<ActivityTypeEnum, List<PracticeTarget>>> _fillActivityQueue(
List<PangeaToken> tokens,
) async {
final queue = <ActivityTypeEnum, List<PracticeTarget>>{};
for (final type in ActivityTypeEnum.practiceTypes) {
queue[type] = await _buildActivity(type, tokens);
}
return queue;
}
static int _sortTokens(
PangeaToken a,
PangeaToken b,
int aScore,
int bScore,
) =>
bScore.compareTo(aScore);
static int _sortMorphTargets(
PracticeTarget a,
PracticeTarget b,
int aScore,
int bScore,
) =>
bScore.compareTo(aScore);
static List<PracticeTarget> _tokenToMorphTargets(PangeaToken t) {
return t.morphsBasicallyEligibleForPracticeByPriority
.map(
(m) => PracticeTarget(
tokens: [t],
activityType: ActivityTypeEnum.morphId,
morphFeature: MorphFeaturesEnumExtension.fromString(m.category),
),
)
.toList();
}
static Future<List<PracticeTarget>> _buildActivity(
ActivityTypeEnum activityType,
List<PangeaToken> tokens,
) async {
if (activityType == ActivityTypeEnum.morphId) {
return _buildMorphActivity(tokens);
}
List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
final seenTexts = <String>{};
final seenLemmas = <String>{};
practiceTokens.retainWhere(
(token) =>
token.eligibleForPractice(activityType) &&
seenTexts.add(token.text.content.toLowerCase()) &&
seenLemmas.add(token.lemma.text.toLowerCase()),
);
if (practiceTokens.length < activityType.minTokensForMatchActivity) {
return [];
}
final scores = await _fetchPriorityScores(
practiceTokens,
activityType,
);
practiceTokens.sort((a, b) => _sortTokens(a, b, scores[a]!, scores[b]!));
practiceTokens = practiceTokens.take(8).toList();
practiceTokens.shuffle();
return [
PracticeTarget(
activityType: activityType,
tokens: practiceTokens.take(PracticeSelection.maxQueueLength).toList(),
),
];
}
static Future<List<PracticeTarget>> _buildMorphActivity(
List<PangeaToken> tokens,
) async {
final List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
final candidates = practiceTokens.expand(_tokenToMorphTargets).toList();
final scores = await _fetchPriorityScores(
practiceTokens,
ActivityTypeEnum.morphId,
);
candidates.sort(
(a, b) => _sortMorphTargets(
a,
b,
scores[a.tokens.first]!,
scores[b.tokens.first]!,
),
);
final seenTexts = <String>{};
final seenLemmas = <String>{};
candidates.retainWhere(
(target) =>
seenTexts.add(target.tokens.first.text.content.toLowerCase()) &&
seenLemmas.add(target.tokens.first.lemma.text.toLowerCase()),
);
return candidates.take(PracticeSelection.maxQueueLength).toList();
}
static Future<Map<PangeaToken, int>> _fetchPriorityScores(
List<PangeaToken> tokens,
ActivityTypeEnum activityType,
) async {
final scores = <PangeaToken, int>{};
for (final token in tokens) {
scores[token] = 0;
}
final ids = tokens.map((t) => t.vocabConstructID).toList();
final idMap = {
for (final token in tokens) token: token.vocabConstructID,
};
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getConstructUses(ids);
for (final token in tokens) {
final construct = constructs[idMap[token]];
final lastUsed =
construct?.lastUseByTypes(activityType.associatedUseTypes);
final daysSinceLastUsed =
lastUsed == null ? 20 : DateTime.now().difference(lastUsed).inDays;
scores[token] =
daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 9);
}
return scores;
}
}