fluffychat/lib/pangea/practice_activities/practice_selection_repo.dart
wcjord 8a3979c61b
feat: unified use-type-aware practice scoring (#5703)
* feat: unified use-type-aware practice scoring on ConstructUses

- Add practiceScore() and practiceTier to ConstructUses for shared
  scoring across message practice and standalone practice
- Add isChatUse, isAssistedChatUse, isIncorrectPractice getters to
  ConstructUseTypeEnum with exhaustive switches
- Add PracticeTier enum (suppressed/active/maintenance)
- Wire into PracticeSelectionRepo and AnalyticsPracticeSessionRepo
- 28 unit tests covering tier classification, scoring, and ordering

Closes #5700

* formatting, fix linting issue

* move some stuff around

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
2026-02-16 12:13:46 -05:00

249 lines
7 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,
langCode.split('-')[0],
);
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,
String language,
) async {
final queue = <ActivityTypeEnum, List<PracticeTarget>>{};
for (final type in ActivityTypeEnum.practiceTypes) {
queue[type] = await _buildActivity(type, tokens, language);
}
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,
String language,
) async {
if (activityType == ActivityTypeEnum.morphId) {
return _buildMorphActivity(tokens, language);
}
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,
language,
);
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,
String language,
) async {
final List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
final candidates = practiceTokens.expand(_tokenToMorphTargets).toList();
final scores = await _fetchPriorityScores(
practiceTokens,
ActivityTypeEnum.morphId,
language,
);
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,
String language,
) 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, language);
for (final token in tokens) {
final id = idMap[token]!;
scores[token] =
constructs[id]?.practiceScore(activityType: activityType) ??
id.unseenPracticeScore;
}
return scores;
}
}