feat(lemma meaning activity): widen distractor range, reduce lemmas w… (#1469)

* feat(lemma meaning activity): widen distractor range, reduce lemmas where meaning activity required

* feat(lemma meaning activities): make distractor lemmas have same pos

* dev(lemma meaning repo): use local storage instead of in-memory cache

* fix(lemma meaning activity): explicitly prevent the same meanings in distractors

* fix: dart formatting, deleted empty files

---------

Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com>
Co-authored-by: ggurdin <ggurdin@gmail.com>
pull/1593/head
wcjord 10 months ago committed by GitHub
parent fe34444797
commit 77c4f711b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/analytics/repo/lemma_info_request.dart';
@ -15,27 +16,20 @@ import '../../common/config/environment.dart';
import '../../common/network/requests.dart';
class LemmaInfoRepo {
// In-memory cache with timestamps
static final Map<LemmaInfoRequest, LemmaInfoResponse> _cache = {};
static final Map<LemmaInfoRequest, DateTime> _cacheTimestamps = {};
static const Duration _cacheDuration = Duration(days: 30);
static final GetStorage _lemmaStorage = GetStorage('lemma_storage');
static void set(LemmaInfoRequest request, LemmaInfoResponse response) {
_cache[request] = response;
// set it to sometime in the future so we keep it in the cache for a while
_cacheTimestamps[request] = DateTime.now().add(const Duration(days: 365));
_lemmaStorage.write(request.storageKey, response.toJson());
}
static Future<LemmaInfoResponse> get(
LemmaInfoRequest request, [
String? feedback,
]) async {
_clearExpiredEntries();
final cachedJson = _lemmaStorage.read(request.storageKey);
if (_cache.containsKey(request)) {
final cached = _cache[request]!;
if (cachedJson != null) {
final cached = LemmaInfoResponse.fromJson(cachedJson);
if (feedback == null) {
// in this case, we just return the cached response
@ -57,7 +51,7 @@ class LemmaInfoRepo {
data: request.toJson(),
);
} else {
debugPrint('No cached response for lemma ${request.lemma}');
debugPrint('No cached response for lemma ${request.lemma}, calling API');
}
final Requests req = Requests(
@ -65,51 +59,16 @@ class LemmaInfoRepo {
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final requestBody = request.toJson();
final Response res = await req.post(
url: PApiUrls.lemmaDictionary,
body: requestBody,
body: request.toJson(),
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = LemmaInfoResponse.fromJson(decodedBody);
// Store the response and timestamp in the cache
_cache[request] = response;
_cacheTimestamps[request] = DateTime.now();
set(request, response);
return response;
}
/// From the cache, get a random set of cached definitions that are not for a specific lemma
static List<String> getDistractorDefinitions(
String lemma,
int count,
) {
_clearExpiredEntries();
final Set<String> definitions = {};
for (final entry in _cache.entries) {
if (entry.key.lemma != lemma) {
definitions.add(entry.value.meaning);
}
}
definitions.toList().shuffle();
return definitions.take(count).toList();
}
static void _clearExpiredEntries() {
final now = DateTime.now();
final expiredKeys = _cacheTimestamps.entries
.where((entry) => now.difference(entry.value) > _cacheDuration)
.map((entry) => entry.key)
.toList();
for (final key in expiredKeys) {
_cache.remove(key);
_cacheTimestamps.remove(key);
}
}
}

@ -40,4 +40,8 @@ class LemmaInfoRequest {
@override
int get hashCode =>
lemma.hashCode ^ partOfSpeech.hashCode ^ feedback.hashCode;
String get storageKey {
return 'l:$lemma,p:$partOfSpeech,lang:$lemmaLang,l1:$userL1';
}
}

@ -1,5 +1,4 @@
import 'dart:developer';
import 'dart:math';
import 'package:flutter/foundation.dart';
@ -19,6 +18,8 @@ import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_meaning_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../analytics/models/lemma.dart';
import '../../common/constants/model_keys.dart';
@ -314,14 +315,14 @@ class PangeaToken {
]) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
if (daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) < 1) {
if (daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) < 7) {
return false;
}
if (isContentWord) {
return vocabConstruct.points < 30;
return vocabConstruct.points < 3;
} else if (canBeDefined) {
return vocabConstruct.points < 5;
return vocabConstruct.points < 1;
} else {
return false;
}
@ -398,10 +399,10 @@ class PangeaToken {
);
return distractors.isNotEmpty;
case ActivityTypeEnum.wordMeaning:
return LemmaInfoRepo.getDistractorDefinitions(
return LemmaMeaningActivityGenerator.canGenerateDistractors(
lemma.text,
1,
).isNotEmpty;
pos,
);
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
@ -410,7 +411,8 @@ class PangeaToken {
}
Future<bool> canGenerateLemmaDistractors() async {
final distractors = await lemmaActivityDistractors(this);
final distractors =
await LemmaActivityGenerator().lemmaActivityDistractors(this);
return distractors.isNotEmpty;
}
@ -657,72 +659,4 @@ class PangeaToken {
possibleDistractors.shuffle();
return possibleDistractors.take(3).toList();
}
Future<List<String>> lemmaActivityDistractors(PangeaToken token) async {
final List<String> lemmas = MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
// Offload computation to an isolate
final Map<String, int> distances =
await compute(_computeDistancesInIsolate, {
'lemmas': lemmas,
'target': token.lemma.text,
});
// Sort lemmas by distance
final sortedLemmas = distances.keys.toList()
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
// Take the shortest 4
final choices = sortedLemmas.take(4).toList();
if (!choices.contains(token.lemma.text)) {
final random = Random();
choices[random.nextInt(4)] = token.lemma.text;
}
return choices;
}
// isolate helper function
Map<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
final List<String> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> distances = {};
for (final lemma in lemmas) {
distances[lemma] = levenshteinDistanceSync(target, lemma);
}
return distances;
}
int levenshteinDistanceSync(String s, String t) {
final int m = s.length;
final int n = t.length;
final List<List<int>> dp = List.generate(
m + 1,
(_) => List.generate(n + 1, (_) => 0),
);
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else if (s[i - 1] == t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 +
[dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]]
.reduce((a, b) => a < b ? a : b);
}
}
}
return dp[m][n];
}
}

@ -1,14 +1,18 @@
import 'dart:developer';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics/enums/construct_type_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaActivityGenerator {
Future<MessageActivityResponse> get(
@ -18,7 +22,7 @@ class LemmaActivityGenerator {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final token = req.targetTokens.first;
final List<String> choices = await token.lemmaActivityDistractors(token);
final List<String> choices = await lemmaActivityDistractors(token);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
@ -36,4 +40,72 @@ class LemmaActivityGenerator {
),
);
}
Future<List<String>> lemmaActivityDistractors(PangeaToken token) async {
final List<String> lemmas = MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
// Offload computation to an isolate
final Map<String, int> distances =
await compute(_computeDistancesInIsolate, {
'lemmas': lemmas,
'target': token.lemma.text,
});
// Sort lemmas by distance
final sortedLemmas = distances.keys.toList()
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
// Take the shortest 4
final choices = sortedLemmas.take(4).toList();
if (!choices.contains(token.lemma.text)) {
final random = Random();
choices[random.nextInt(4)] = token.lemma.text;
}
return choices;
}
// isolate helper function
Map<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
final List<String> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> distances = {};
for (final lemma in lemmas) {
distances[lemma] = levenshteinDistanceSync(target, lemma);
}
return distances;
}
int levenshteinDistanceSync(String s, String t) {
final int m = s.length;
final int n = t.length;
final List<List<int>> dp = List.generate(
m + 1,
(_) => List.generate(n + 1, (_) => 0),
);
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else if (s[i - 1] == t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 +
[dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]]
.reduce((a, b) => a < b ? a : b);
}
}
}
return dp[m][n];
}
}

@ -3,14 +3,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics/enums/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics/models/constructs_model.dart';
import 'package:fluffychat/pangea/analytics/repo/lemma_info_repo.dart';
import 'package:fluffychat/pangea/analytics/repo/lemma_info_request.dart';
import 'package:fluffychat/pangea/analytics/repo/lemma_info_response.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class WordMeaningActivityGenerator {
class LemmaMeaningActivityGenerator {
Future<MessageActivityResponse> get(
MessageActivityRequest req,
BuildContext context,
@ -33,8 +36,7 @@ class WordMeaningActivityGenerator {
final res = await LemmaInfoRepo.get(lemmaDefReq);
final choices =
LemmaInfoRepo.getDistractorDefinitions(lemmaDefReq.lemma, 3);
final choices = await getDistractorMeanings(lemmaDefReq, 3);
if (!choices.contains(res.meaning)) {
choices.add(res.meaning);
@ -57,4 +59,51 @@ class WordMeaningActivityGenerator {
),
);
}
static List<OneConstructUse> eligibleDistractors(String lemma, String pos) {
return MatrixState.pangeaController.getAnalytics.constructListModel.uses
.where(
(c) =>
c.lemma.toLowerCase() != lemma.toLowerCase() &&
c.category.toLowerCase() == pos.toLowerCase() &&
c.constructType == ConstructTypeEnum.vocab,
)
.toList();
}
/// From the cache, get a random set of cached definitions that are not for a specific lemma
static Future<List<String>> getDistractorMeanings(
LemmaInfoRequest req,
int count,
) async {
final eligible = eligibleDistractors(req.lemma, req.partOfSpeech);
eligible.shuffle();
final List<OneConstructUse> distractorConstructUses =
eligible.take(count).toList();
final List<Future<LemmaInfoResponse>> futureDefs = [];
for (final construct in distractorConstructUses) {
futureDefs.add(
LemmaInfoRepo.get(
LemmaInfoRequest(
lemma: construct.lemma,
partOfSpeech: construct.category,
lemmaLang: req.lemmaLang,
userL1: req.userL1,
),
),
);
}
final Set<String> distractorDefs = {};
for (final def in await Future.wait(futureDefs)) {
distractorDefs.add(def.meaning);
}
return distractorDefs.toList();
}
static bool canGenerateDistractors(String lemma, String pos) =>
eligibleDistractors(lemma, pos).isNotEmpty;
}

@ -21,8 +21,8 @@ import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/repo/emoji_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_meaning_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/morph_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/word_meaning_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Represents an item in the completion cache.
@ -47,7 +47,7 @@ class PracticeGenerationController {
final _morph = MorphActivityGenerator();
final _emoji = EmojiActivityGenerator();
final _lemma = LemmaActivityGenerator();
final _wordMeaning = WordMeaningActivityGenerator();
final _wordMeaning = LemmaMeaningActivityGenerator();
PracticeGenerationController() {
_pangeaController = MatrixState.pangeaController;

Loading…
Cancel
Save