From 84737dbca1b9e63523a2e300a2af31104656677a Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 9 Dec 2025 16:20:17 -0500 Subject: [PATCH] update lemma meaning and phonetic transcription repos --- lib/pangea/common/utils/async_state.dart | 6 + .../constructs/construct_identifier.dart | 4 +- lib/pangea/languages/language_arc_model.dart | 8 + lib/pangea/lemmas/lemma_info_repo.dart | 165 ++++++++++++++---- lib/pangea/lemmas/lemma_info_response.dart | 6 - lib/pangea/lemmas/lemma_meaning_builder.dart | 13 +- .../phonetic_transcription_builder.dart | 14 +- .../phonetic_transcription_repo.dart | 157 +++++++++++++---- .../phonetic_transcription_request.dart | 12 ++ .../phonetic_transcription_response.dart | 6 - .../emoji_activity_generator.dart | 17 +- .../lemma_meaning_activity_generator.dart | 14 +- .../practice_generation_repo.dart | 3 + 13 files changed, 337 insertions(+), 88 deletions(-) diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart index a9fd60cb5..f14b88a61 100644 --- a/lib/pangea/common/utils/async_state.dart +++ b/lib/pangea/common/utils/async_state.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -88,6 +90,10 @@ abstract class AsyncLoader { final result = await fetch(); if (_disposed) return; state.value = AsyncState.loaded(result); + } on HttpException catch (e) { + if (!_disposed) { + state.value = AsyncState.error(e); + } } catch (e, s) { ErrorHandler.logError( e: e, diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index df4887fbb..aa980be0a 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:async/async.dart'; import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -162,7 +163,8 @@ class ConstructIdentifier { ); /// [lemmmaLang] if not set, assumed to be userL2 - Future getLemmaInfo() => LemmaInfoRepo.get( + Future> getLemmaInfo() => LemmaInfoRepo.get( + MatrixState.pangeaController.userController.accessToken, lemmaInfoRequest, ); diff --git a/lib/pangea/languages/language_arc_model.dart b/lib/pangea/languages/language_arc_model.dart index 3e450c9c2..c55d8a66b 100644 --- a/lib/pangea/languages/language_arc_model.dart +++ b/lib/pangea/languages/language_arc_model.dart @@ -22,4 +22,12 @@ class LanguageArc { 'l2': l2.toJson(), }; } + + @override + int get hashCode => l1.hashCode ^ l2.hashCode; + + @override + bool operator ==(Object other) { + return other is LanguageArc && other.l1 == l1 && other.l2 == l2; + } } diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 420c50420..02d6712ef 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -1,51 +1,105 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:async/async.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; -import 'package:fluffychat/widgets/matrix.dart'; + +class _LemmaInfoCacheItem { + final Future> resultFuture; + final DateTime timestamp; + + const _LemmaInfoCacheItem({ + required this.resultFuture, + required this.timestamp, + }); +} class LemmaInfoRepo { - static final GetStorage _lemmaStorage = GetStorage('lemma_storage'); + // In-memory cache + static final Map _cache = {}; + static const Duration _cacheDuration = Duration(minutes: 10); + + // Persistent storage + static final GetStorage _storage = GetStorage('lemma_storage'); + + /// Public entry point + static Future> get( + String accessToken, + LemmaInfoRequest request, + ) { + // 1. Try memory cache + final cached = _getCached(request); + if (cached != null) { + return cached; + } + + // 2. Try disk cache + final stored = _getStored(request); + if (stored != null) { + return Future.value(Result.value(stored)); + } + + // 3. Fetch from network (safe future) + final future = _safeFetch(accessToken, request); + + // 4. Save to in-memory cache + _cache[request.hashCode.toString()] = _LemmaInfoCacheItem( + resultFuture: future, + timestamp: DateTime.now(), + ); + + // 5. Write to disk *after* the fetch finishes, without rethrowing + writeToDisk(request, future); + + return future; + } static Future set( LemmaInfoRequest request, - LemmaInfoResponse response, + LemmaInfoResponse resultFuture, ) async { - // set expireAt if not set - response.expireAt ??= DateTime.now().add(const Duration(days: 100)); - await _lemmaStorage.write(request.storageKey, response.toJson()); - } - - static LemmaInfoResponse? getCached(LemmaInfoRequest request) { - final cachedJson = _lemmaStorage.read(request.storageKey); - - final cached = - cachedJson == null ? null : LemmaInfoResponse.fromJson(cachedJson); - - if (cached != null) { - if (DateTime.now().isBefore(cached.expireAt!)) { - return cached; - } else { - _lemmaStorage.remove(request.storageKey); - } + final key = request.hashCode.toString(); + try { + await _storage.write(key, resultFuture.toJson()); + _cache.remove(key); // Invalidate in-memory cache + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'lemma': request.lemma}, + ); } - return null; } - /// Get lemma info, prefering user set data over fetched data - static Future get(LemmaInfoRequest request) async { - final cached = getCached(request); - if (cached != null) return cached; + static Future> _safeFetch( + String token, + LemmaInfoRequest request, + ) async { + try { + final resp = await _fetch(token, request); + return Result.value(resp); + } catch (e, s) { + // Ensure error is logged and converted to a Result + ErrorHandler.logError(e: e, s: s, data: request.toJson()); + return Result.error(e); + } + } - final Requests req = Requests( + static Future _fetch( + String accessToken, + LemmaInfoRequest request, + ) async { + final req = Requests( choreoApiKey: Environment.choreoApiKey, - accessToken: MatrixState.pangeaController.userController.accessToken, + accessToken: accessToken, ); final Response res = await req.post( @@ -53,10 +107,59 @@ class LemmaInfoRepo { body: request.toJson(), ); - final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); - final response = LemmaInfoResponse.fromJson(decodedBody); + if (res.statusCode != 200) { + throw HttpException( + 'Failed to fetch lemma info: ${res.statusCode} ${res.reasonPhrase}', + ); + } - set(request, response); - return response; + return LemmaInfoResponse.fromJson( + jsonDecode(utf8.decode(res.bodyBytes)), + ); + } + + static Future>? _getCached( + LemmaInfoRequest request, + ) { + final now = DateTime.now(); + final key = request.hashCode.toString(); + + // Remove stale entries first + _cache.removeWhere( + (_, item) => now.difference(item.timestamp) >= _cacheDuration, + ); + + final item = _cache[key]; + return item?.resultFuture; + } + + static Future writeToDisk( + LemmaInfoRequest request, + Future> resultFuture, + ) async { + final result = await resultFuture; // SAFE: never throws + + if (!result.isValue) return; // only cache successful responses + await set(request, result.asValue!.value); + } + + static LemmaInfoResponse? _getStored( + LemmaInfoRequest request, + ) { + final key = request.hashCode.toString(); + try { + final entry = _storage.read(key); + if (entry == null) return null; + + return LemmaInfoResponse.fromJson(entry); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'lemma': request.lemma}, + ); + _storage.remove(key); + return null; + } } } diff --git a/lib/pangea/lemmas/lemma_info_response.dart b/lib/pangea/lemmas/lemma_info_response.dart index ffd5d41bd..6edec1fc7 100644 --- a/lib/pangea/lemmas/lemma_info_response.dart +++ b/lib/pangea/lemmas/lemma_info_response.dart @@ -3,12 +3,10 @@ import 'package:fluffychat/pangea/events/models/content_feedback.dart'; class LemmaInfoResponse implements JsonSerializable { final List emoji; final String meaning; - DateTime? expireAt; LemmaInfoResponse({ required this.emoji, required this.meaning, - this.expireAt, }); factory LemmaInfoResponse.fromJson(Map json) { @@ -16,9 +14,6 @@ class LemmaInfoResponse implements JsonSerializable { // NOTE: This is a workaround for the fact that the server sometimes sends more than 3 emojis emoji: (json['emoji'] as List).map((e) => e as String).toList(), meaning: json['meaning'] as String, - expireAt: json['expireAt'] == null - ? null - : DateTime.parse(json['expireAt'] as String), ); } @@ -32,7 +27,6 @@ class LemmaInfoResponse implements JsonSerializable { return { 'emoji': emoji, 'meaning': meaning, - 'expireAt': expireAt?.toIso8601String(), }; } diff --git a/lib/pangea/lemmas/lemma_meaning_builder.dart b/lib/pangea/lemmas/lemma_meaning_builder.dart index 405e8491d..30603329c 100644 --- a/lib/pangea/lemmas/lemma_meaning_builder.dart +++ b/lib/pangea/lemmas/lemma_meaning_builder.dart @@ -13,7 +13,18 @@ class _LemmaMeaningLoader extends AsyncLoader { _LemmaMeaningLoader(this.request) : super(); @override - Future fetch() => LemmaInfoRepo.get(request); + Future fetch() async { + final result = await LemmaInfoRepo.get( + MatrixState.pangeaController.userController.accessToken, + request, + ); + + if (result.isError) { + throw result.asError!.error; + } + + return result.asValue!.value; + } } class LemmaMeaningBuilder extends StatefulWidget { diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart index 41c25101b..366f9b1fe 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart @@ -14,9 +14,17 @@ class _TranscriptLoader extends AsyncLoader { @override Future fetch() async { - final resp = await PhoneticTranscriptionRepo.get(request); - return resp.phoneticTranscriptionResult.phoneticTranscription.first - .phoneticL1Transcription.content; + final resp = await PhoneticTranscriptionRepo.get( + MatrixState.pangeaController.userController.accessToken, + request, + ); + + if (resp.isError) { + throw resp.asError!.error; + } + + return resp.asValue!.value.phoneticTranscriptionResult.phoneticTranscription + .first.phoneticL1Transcription.content; } } diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart index 7fe6885fe..33760bc9a 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart @@ -1,8 +1,7 @@ import 'dart:convert'; -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; +import 'dart:io'; +import 'package:async/async.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; @@ -12,39 +11,95 @@ import 'package:fluffychat/pangea/common/network/urls.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart'; -import 'package:fluffychat/widgets/matrix.dart'; + +class _PhoneticTranscriptionCacheItem { + final Future> resultFuture; + final DateTime timestamp; + + const _PhoneticTranscriptionCacheItem({ + required this.resultFuture, + required this.timestamp, + }); +} class PhoneticTranscriptionRepo { + // In-memory cache + static final Map _cache = {}; + static const Duration _cacheDuration = Duration(minutes: 10); + +// Persistent storage static final GetStorage _storage = GetStorage('phonetic_transcription_storage'); + static Future> get( + String accessToken, + PhoneticTranscriptionRequest request, + ) { + // 1. Try memory cache + final cached = _getCached(request); + if (cached != null) { + return cached; + } + + // 2. Try disk cache + final stored = _getStored(request); + if (stored != null) { + return Future.value(Result.value(stored)); + } + + // 3. Fetch from network (safe future) + final future = _safeFetch(accessToken, request); + + // 4. Save to in-memory cache + _cache[request.hashCode.toString()] = _PhoneticTranscriptionCacheItem( + resultFuture: future, + timestamp: DateTime.now(), + ); + + // 5. Write to disk *after* the fetch finishes, without rethrowing + writeToDisk(request, future); + + return future; + } + static Future set( PhoneticTranscriptionRequest request, - PhoneticTranscriptionResponse response, + PhoneticTranscriptionResponse resultFuture, ) async { - response.expireAt ??= DateTime.now().add(const Duration(days: 100)); - await _storage.write(request.storageKey, response.toJson()); + final key = request.hashCode.toString(); + try { + await _storage.write(key, resultFuture.toJson()); + _cache.remove(key); // Invalidate in-memory cache + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'request': request.toJson()}, + ); + } + } + + static Future> _safeFetch( + String token, + PhoneticTranscriptionRequest request, + ) async { + try { + final resp = await _fetch(token, request); + return Result.value(resp); + } catch (e, s) { + // Ensure error is logged and converted to a Result + ErrorHandler.logError(e: e, s: s, data: request.toJson()); + return Result.error(e); + } } static Future _fetch( + String accessToken, PhoneticTranscriptionRequest request, ) async { - final cachedJson = _storage.read(request.storageKey); - final cached = cachedJson == null - ? null - : PhoneticTranscriptionResponse.fromJson(cachedJson); - - if (cached != null) { - if (DateTime.now().isBefore(cached.expireAt!)) { - return cached; - } else { - _storage.remove(request.storageKey); - } - } - - final Requests req = Requests( + final req = Requests( choreoApiKey: Environment.choreoApiKey, - accessToken: MatrixState.pangeaController.userController.accessToken, + accessToken: accessToken, ); final Response res = await req.post( @@ -52,21 +107,59 @@ class PhoneticTranscriptionRepo { body: request.toJson(), ); - final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); - final response = PhoneticTranscriptionResponse.fromJson(decodedBody); - set(request, response); - return response; + if (res.statusCode != 200) { + throw HttpException( + 'Failed to fetch phonetic transcription: ${res.statusCode} ${res.reasonPhrase}', + ); + } + + return PhoneticTranscriptionResponse.fromJson( + jsonDecode(utf8.decode(res.bodyBytes)), + ); } - static Future get( + static Future>? _getCached( PhoneticTranscriptionRequest request, + ) { + final now = DateTime.now(); + final key = request.hashCode.toString(); + + // Remove stale entries first + _cache.removeWhere( + (_, item) => now.difference(item.timestamp) >= _cacheDuration, + ); + + final item = _cache[key]; + return item?.resultFuture; + } + + static Future writeToDisk( + PhoneticTranscriptionRequest request, + Future> resultFuture, ) async { + final result = await resultFuture; // SAFE: never throws + + if (!result.isValue) return; // only cache successful responses + await set(request, result.asValue!.value); + } + + static PhoneticTranscriptionResponse? _getStored( + PhoneticTranscriptionRequest request, + ) { + final key = request.hashCode.toString(); try { - return await _fetch(request); - } catch (e) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: e, data: request.toJson()); - rethrow; + final entry = _storage.read(key); + if (entry == null) return null; + + return PhoneticTranscriptionResponse.fromJson(entry); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'request': request.toJson()}, + ); + _storage.remove(key); + return null; } } } diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_request.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_request.dart index be229cba1..464193b01 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_request.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_request.dart @@ -30,4 +30,16 @@ class PhoneticTranscriptionRequest { } String get storageKey => '${arc.l1}-${arc.l2}-${content.hashCode}'; + + @override + int get hashCode => + content.hashCode ^ arc.hashCode ^ requiresTokenization.hashCode; + + @override + bool operator ==(Object other) { + return other is PhoneticTranscriptionRequest && + other.content == content && + other.arc == arc && + other.requiresTokenization == requiresTokenization; + } } diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_response.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_response.dart index 0f9a62b43..612ef42c1 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_response.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_response.dart @@ -102,14 +102,12 @@ class PhoneticTranscriptionResponse { final Map tokenization; // You can define a typesafe model if needed final PhoneticTranscription phoneticTranscriptionResult; - DateTime? expireAt; PhoneticTranscriptionResponse({ required this.arc, required this.content, required this.tokenization, required this.phoneticTranscriptionResult, - this.expireAt, }); factory PhoneticTranscriptionResponse.fromJson(Map json) { @@ -121,9 +119,6 @@ class PhoneticTranscriptionResponse { phoneticTranscriptionResult: PhoneticTranscription.fromJson( json['phonetic_transcription_result'] as Map, ), - expireAt: json['expireAt'] == null - ? null - : DateTime.parse(json['expireAt'] as String), ); } @@ -133,7 +128,6 @@ class PhoneticTranscriptionResponse { 'content': content.toJson(), 'tokenization': tokenization, 'phonetic_transcription_result': phoneticTranscriptionResult.toJson(), - 'expireAt': expireAt?.toIso8601String(), }; } diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 3354421c5..3cb5584bd 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -1,3 +1,5 @@ +import 'package:async/async.dart'; + import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; @@ -35,15 +37,20 @@ class EmojiActivityGenerator { } } - final List> lemmaInfoFutures = missingEmojis - .map((token) => token.vocabConstructID.getLemmaInfo()) - .toList(); + final List>> lemmaInfoFutures = + missingEmojis + .map((token) => token.vocabConstructID.getLemmaInfo()) + .toList(); - final List lemmaInfos = + final List> lemmaInfos = await Future.wait(lemmaInfoFutures); for (int i = 0; i < missingEmojis.length; i++) { - final e = lemmaInfos[i].emoji.firstWhere( + if (lemmaInfos[i].isError) { + throw lemmaInfos[i].asError!.error; + } + + final e = lemmaInfos[i].asValue!.value.emoji.firstWhere( (e) => !usedEmojis.contains(e), orElse: () => throw Exception( "Not enough unique emojis for tokens in message", diff --git a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index dfe8515d9..02d839940 100644 --- a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -1,26 +1,34 @@ import 'dart:async'; +import 'package:async/async.dart'; + import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; class LemmaMeaningActivityGenerator { static Future get( MessageActivityRequest req, ) async { - final List> lemmaInfoFutures = req.targetTokens + final List>> lemmaInfoFutures = req + .targetTokens .map((token) => token.vocabConstructID.getLemmaInfo()) .toList(); - final List lemmaInfos = + final List> lemmaInfos = await Future.wait(lemmaInfoFutures); + if (lemmaInfos.any((result) => result.isError)) { + throw lemmaInfos.firstWhere((result) => result.isError).error!; + } + final Map> matchInfo = Map.fromIterables( req.targetTokens.map((token) => token.vocabForm), - lemmaInfos.map((lemmaInfo) => [lemmaInfo.meaning]), + lemmaInfos.map((lemmaInfo) => [lemmaInfo.asValue!.value.meaning]), ); return MessageActivityResponse( diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 008461d2e..423f4033a 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -69,6 +70,8 @@ class PracticeRepo { _setCached(req, res); return Result.value(res.activity); + } on HttpException catch (e, s) { + return Result.error(e, s); } catch (e, s) { ErrorHandler.logError( e: e,