diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index c4a070823..0af1fd607 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -609,7 +609,7 @@ class Choreographer { choreoRecord = null; translatedText = null; itController.clear(); - igc.dispose(); + igc.clear(); _resetDebounceTimer(); } diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index fe2bcd7e8..0f644bd97 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -4,8 +4,8 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:async/async.dart'; +import 'package:matrix/matrix.dart' hide Result; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart'; @@ -16,170 +16,96 @@ import 'package:fluffychat/pangea/choreographer/repo/igc_repo.dart'; import 'package:fluffychat/pangea/choreographer/repo/igc_request_model.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/span_card.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../common/utils/error_handler.dart'; import '../../common/utils/overlay.dart'; -class _IGCTextDataCacheItem { - Future data; - - _IGCTextDataCacheItem({required this.data}); -} - -class _IgnoredMatchCacheItem { - PangeaMatch match; - - String get spanText => match.match.fullText.substring( - match.match.offset, - match.match.offset + match.match.length, - ); - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is _IgnoredMatchCacheItem && other.spanText == spanText; - } - - @override - int get hashCode => spanText.hashCode; - - _IgnoredMatchCacheItem({required this.match}); -} - class IgcController { Choreographer choreographer; IGCTextData? igcTextData; late SpanDataController spanDataController; - // cache for IGC data and prev message - final Map _igcTextDataCache = {}; - - final Map _ignoredMatchCache = {}; - - Timer? _cacheClearTimer; - IgcController(this.choreographer) { spanDataController = SpanDataController(choreographer); - _initializeCacheClearing(); - } - - void _initializeCacheClearing() { - const duration = Duration(minutes: 2); - _cacheClearTimer = Timer.periodic(duration, (Timer t) { - _igcTextDataCache.clear(); - _ignoredMatchCache.clear(); - }); } Future getIGCTextData() async { - try { - if (choreographer.currentText.isEmpty) return clear(); - debugPrint('getIGCTextData called with ${choreographer.currentText}'); + if (choreographer.currentText.isEmpty) return clear(); + debugPrint('getIGCTextData called with ${choreographer.currentText}'); - final IGCRequestModel reqBody = IGCRequestModel( - fullText: choreographer.currentText, - userId: choreographer.pangeaController.userController.userId!, - userL1: choreographer.l1LangCode!, - userL2: choreographer.l2LangCode!, - enableIGC: choreographer.igcEnabled && - choreographer.choreoMode != ChoreoMode.it, - enableIT: choreographer.itEnabled && - choreographer.choreoMode != ChoreoMode.it, - prevMessages: _prevMessages(), - ); + final IGCRequestModel reqBody = IGCRequestModel( + fullText: choreographer.currentText, + userId: choreographer.pangeaController.userController.userId!, + userL1: choreographer.l1LangCode!, + userL2: choreographer.l2LangCode!, + enableIGC: + choreographer.igcEnabled && choreographer.choreoMode != ChoreoMode.it, + enableIT: + choreographer.itEnabled && choreographer.choreoMode != ChoreoMode.it, + prevMessages: _prevMessages(), + ); - if (_cacheClearTimer == null || !_cacheClearTimer!.isActive) { - _initializeCacheClearing(); - } - - // if the request is not in the cache, add it - if (!_igcTextDataCache.containsKey(reqBody.hashCode)) { - _igcTextDataCache[reqBody.hashCode] = _IGCTextDataCacheItem( - data: IgcRepo.getIGC( - choreographer.accessToken, - igcRequest: reqBody, - ), + final res = await IgcRepo.get( + choreographer.accessToken, + reqBody, + ).timeout( + (const Duration(seconds: 10)), + onTimeout: () { + return Result.error( + TimeoutException('IGC request timed out'), ); - } + }, + ); - final IGCTextData igcTextDataResponse = - await _igcTextDataCache[reqBody.hashCode]! - .data - .timeout((const Duration(seconds: 10))); - - // this will happen when the user changes the input while igc is fetching results - if (igcTextDataResponse.originalInput.trim() != - choreographer.currentText.trim()) { - return; - } - // get ignored matches from the original igcTextData - // if the new matches are the same as the original match - // could possibly change the status of the new match - // thing is the same if the text we are trying to change is the smae - // as the new text we are trying to change (suggestion is the same) - - // Check for duplicate or minor text changes that shouldn't trigger suggestions - // checks for duplicate input - - igcTextData = igcTextDataResponse; - - final List filteredMatches = List.from(igcTextData!.matches); - for (final PangeaMatch match in igcTextData!.matches) { - final _IgnoredMatchCacheItem cacheEntry = - _IgnoredMatchCacheItem(match: match); - - if (_ignoredMatchCache.containsKey(cacheEntry.hashCode)) { - filteredMatches.remove(match); - } - } - - igcTextData!.matches = filteredMatches; - choreographer.acceptNormalizationMatches(); - - // TODO - for each new match, - // check if existing igcTextData has one and only one match with the same error text and correction - // if so, keep the original match and discard the new one - // if not, add the new match to the existing igcTextData - - // After fetching igc data, pre-call span details for each match optimistically. - // This will make the loading of span details faster for the user - if (igcTextData?.matches.isNotEmpty ?? false) { - for (int i = 0; i < igcTextData!.matches.length; i++) { - if (!igcTextData!.matches[i].isITStart) { - spanDataController.getSpanDetails(i); - } - } - } - - debugPrint("igc text ${igcTextData.toString()}"); - } catch (err, stack) { - debugger(when: kDebugMode); - choreographer.errorService.setError( - ChoreoError(raw: err), - ); - ErrorHandler.logError( - e: err, - s: stack, - data: { - "currentText": choreographer.currentText, - "userL1": choreographer.l1LangCode, - "userL2": choreographer.l2LangCode, - "igcEnabled": choreographer.igcEnabled, - "itEnabled": choreographer.itEnabled, - "matches": igcTextData?.matches.map((e) => e.toJson()), - }, - level: - err is TimeoutException ? SentryLevel.warning : SentryLevel.error, - ); + if (res.isError) { + choreographer.errorService.setError(ChoreoError(raw: res.error)); clear(); + return; + } + + // this will happen when the user changes the input while igc is fetching results + if (res.result!.originalInput.trim() != choreographer.currentText.trim()) { + return; + } + // get ignored matches from the original igcTextData + // if the new matches are the same as the original match + // could possibly change the status of the new match + // thing is the same if the text we are trying to change is the smae + // as the new text we are trying to change (suggestion is the same) + + // Check for duplicate or minor text changes that shouldn't trigger suggestions + // checks for duplicate input + + igcTextData = res.result!; + final List filteredMatches = List.from(igcTextData!.matches); + for (final PangeaMatch match in igcTextData!.matches) { + if (IgcRepo.isIgnored(match)) { + filteredMatches.remove(match); + } + } + + igcTextData!.matches = filteredMatches; + choreographer.acceptNormalizationMatches(); + + // TODO - for each new match, + // check if existing igcTextData has one and only one match with the same error text and correction + // if so, keep the original match and discard the new one + // if not, add the new match to the existing igcTextData + + // After fetching igc data, pre-call span details for each match optimistically. + // This will make the loading of span details faster for the user + if (igcTextData?.matches.isNotEmpty ?? false) { + for (int i = 0; i < igcTextData!.matches.length; i++) { + if (!igcTextData!.matches[i].isITStart) { + spanDataController.getSpanDetails(i); + } + } } } void onIgnoreMatch(PangeaMatch match) { - final cacheEntry = _IgnoredMatchCacheItem(match: match); - if (!_ignoredMatchCache.containsKey(cacheEntry.hashCode)) { - _ignoredMatchCache[cacheEntry.hashCode] = cacheEntry; - } + IgcRepo.ignore(match); } void showFirstMatch(BuildContext context) { @@ -282,11 +208,4 @@ class IgcController { filter: RegExp(r'span_card_overlay_\d+'), ); } - - dispose() { - clear(); - _igcTextDataCache.clear(); - _ignoredMatchCache.clear(); - _cacheClearTimer?.cancel(); - } } diff --git a/lib/pangea/choreographer/repo/custom_input_request_model.dart b/lib/pangea/choreographer/repo/custom_input_request_model.dart index 64e348d0f..03355af1b 100644 --- a/lib/pangea/choreographer/repo/custom_input_request_model.dart +++ b/lib/pangea/choreographer/repo/custom_input_request_model.dart @@ -1,4 +1,4 @@ -import 'package:fluffychat/pangea/choreographer/repo/it_response_model.dart'; +import 'package:fluffychat/pangea/choreographer/models/it_step.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; class CustomInputRequestModel { diff --git a/lib/pangea/choreographer/repo/full_text_translation_repo.dart b/lib/pangea/choreographer/repo/full_text_translation_repo.dart index ab5250f9c..ca8c5ff10 100644 --- a/lib/pangea/choreographer/repo/full_text_translation_repo.dart +++ b/lib/pangea/choreographer/repo/full_text_translation_repo.dart @@ -3,71 +3,48 @@ import 'dart:async'; import 'dart:convert'; +import 'package:async/async.dart'; import 'package:http/http.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_response_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import '../../common/config/environment.dart'; import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; +class _TranslateCacheItem { + final Future response; + final DateTime timestamp; + + _TranslateCacheItem({ + required this.response, + required this.timestamp, + }); +} + class FullTextTranslationRepo { - static final Map _cache = {}; - static Timer? _cacheTimer; + static final Map _cache = {}; + static const Duration _cacheDuration = Duration(minutes: 10); - // start a timer to clear the cache - static void startCacheTimer() { - _cacheTimer = Timer.periodic(const Duration(minutes: 10), (timer) { - clearCache(); - }); - } - - // stop the cache time (optional) - static void stopCacheTimer() { - _cacheTimer?.cancel(); - } - - // method to clear the cache - static void clearCache() { - _cache.clear(); - } - - static String _generateCacheKey({ - required String text, - required String srcLang, - required String tgtLang, - required int offset, - required int length, - bool? deepL, - }) { - return '${text.hashCode}-$srcLang-$tgtLang-$deepL-$offset-$length'; - } - - static Future translate({ - required String accessToken, - required FullTextTranslationRequestModel request, - }) async { - // start cache timer when the first API call is made - startCacheTimer(); - - final cacheKey = _generateCacheKey( - text: request.text, - srcLang: request.srcLang ?? '', - tgtLang: request.tgtLang, - offset: request.offset ?? 0, - length: request.length ?? 0, - deepL: request.deepL, - ); - - // check cache first - if (_cache.containsKey(cacheKey)) { - if (_cache[cacheKey] == null) { - _cache.remove(cacheKey); - } else { - return _cache[cacheKey]!; - } + static Future> get( + String accessToken, + FullTextTranslationRequestModel request, + ) { + final cached = _getCached(request); + if (cached != null) { + return _getResult(request, cached); } + final future = _fetch(accessToken, request); + _setCached(request, future); + return _getResult(request, future); + } + + static Future _fetch( + String accessToken, + FullTextTranslationRequestModel request, + ) async { final Requests req = Requests( choreoApiKey: Environment.choreoApiKey, accessToken: accessToken, @@ -78,13 +55,57 @@ class FullTextTranslationRepo { body: request.toJson(), ); - final responseModel = FullTextTranslationResponseModel.fromJson( + if (res.statusCode != 200) { + throw Exception( + 'Failed to translate text: ${res.statusCode} ${res.reasonPhrase}', + ); + } + + return FullTextTranslationResponseModel.fromJson( jsonDecode(utf8.decode(res.bodyBytes)), ); - - // store response in cache - _cache[cacheKey] = responseModel; - - return responseModel; } + + static Future> _getResult( + FullTextTranslationRequestModel request, + Future future, + ) async { + try { + final res = await future; + return Result.value(res); + } catch (e, s) { + _cache.remove(request.hashCode.toString()); + ErrorHandler.logError( + e: e, + s: s, + data: request.toJson(), + ); + return Result.error(e); + } + } + + static Future? _getCached( + FullTextTranslationRequestModel request, + ) { + final cached = _cache[request.hashCode.toString()]; + if (cached == null) { + return null; + } + + if (DateTime.now().difference(cached.timestamp) < _cacheDuration) { + return cached.response; + } + + _cache.remove(request.hashCode.toString()); + return null; + } + + static void _setCached( + FullTextTranslationRequestModel request, + Future response, + ) => + _cache[request.hashCode.toString()] = _TranslateCacheItem( + response: response, + timestamp: DateTime.now(), + ); } diff --git a/lib/pangea/choreographer/repo/igc_repo.dart b/lib/pangea/choreographer/repo/igc_repo.dart index 293a33e18..99e16c067 100644 --- a/lib/pangea/choreographer/repo/igc_repo.dart +++ b/lib/pangea/choreographer/repo/igc_repo.dart @@ -1,15 +1,75 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; import 'package:http/http.dart'; +import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/igc_request_model.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import '../../common/network/requests.dart'; import '../../common/network/urls.dart'; import '../models/igc_text_data_model.dart'; +class _IgcCacheItem { + final Future data; + final DateTime timestamp; + + _IgcCacheItem({ + required this.data, + required this.timestamp, + }); +} + +class _IgnoredMatchCacheItem { + final PangeaMatch match; + final DateTime timestamp; + + String get spanText => match.match.fullText.characters + .skip(match.match.offset) + .take(match.match.length) + .toString(); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _IgnoredMatchCacheItem && other.spanText == spanText; + } + + @override + int get hashCode => spanText.hashCode; + + _IgnoredMatchCacheItem({ + required this.match, + required this.timestamp, + }); +} + class IgcRepo { - static Future getIGC( + static final Map _igcCache = {}; + static final Map _ignoredMatchCache = {}; + static const Duration _cacheDuration = Duration(minutes: 10); + + static Future> get( + String? accessToken, + IGCRequestModel igcRequest, + ) { + final cached = _getCached(igcRequest); + if (cached != null) { + return _getResult(igcRequest, cached); + } + + final future = _fetch( + accessToken, + igcRequest: igcRequest, + ); + _setCached(igcRequest, future); + return _getResult(igcRequest, future); + } + + static Future _fetch( String? accessToken, { required IGCRequestModel igcRequest, }) async { @@ -22,11 +82,98 @@ class IgcRepo { body: igcRequest.toJson(), ); + if (res.statusCode != 200) { + throw Exception( + 'Failed to fetch IGC data: ${res.statusCode} ${res.reasonPhrase}', + ); + } + final Map json = jsonDecode(utf8.decode(res.bodyBytes).toString()); - final IGCTextData response = IGCTextData.fromJson(json); + return IGCTextData.fromJson(json); + } - return response; + static Future> _getResult( + IGCRequestModel request, + Future future, + ) async { + try { + final res = await future; + return Result.value(res); + } catch (e, s) { + _igcCache.remove(request.hashCode.toString()); + ErrorHandler.logError( + e: e, + s: s, + data: request.toJson(), + ); + return Result.error(e); + } + } + + static Future? _getCached( + IGCRequestModel request, + ) { + final cached = _igcCache[request.hashCode.toString()]; + if (cached == null) { + return null; + } + + if (DateTime.now().difference(cached.timestamp) < _cacheDuration) { + return cached.data; + } + + _igcCache.remove(request.hashCode.toString()); + return null; + } + + static void _setCached( + IGCRequestModel request, + Future response, + ) => + _igcCache[request.hashCode.toString()] = _IgcCacheItem( + data: response, + timestamp: DateTime.now(), + ); + + static void ignore(PangeaMatch match) { + _setCachedIgnoredSpan(match); + } + + static bool isIgnored(PangeaMatch match) { + final cached = _getCachedIgnoredSpan(match); + return cached != null; + } + + static PangeaMatch? _getCachedIgnoredSpan( + PangeaMatch match, + ) { + final cacheEntry = _IgnoredMatchCacheItem( + match: match, + timestamp: DateTime.now(), + ); + + final cached = _ignoredMatchCache[cacheEntry.hashCode.toString()]; + if (cached == null) { + return null; + } + + if (DateTime.now().difference(cached.timestamp) < _cacheDuration) { + return cached.match; + } + + _ignoredMatchCache.remove(cacheEntry.hashCode.toString()); + return null; + } + + static void _setCachedIgnoredSpan( + PangeaMatch match, + ) { + final cacheEntry = _IgnoredMatchCacheItem( + match: match, + timestamp: DateTime.now(), + ); + _ignoredMatchCache[cacheEntry.hashCode.toString()] = cacheEntry; } } diff --git a/lib/pangea/choreographer/widgets/it_feedback_card.dart b/lib/pangea/choreographer/widgets/it_feedback_card.dart index 183347953..3849c27d4 100644 --- a/lib/pangea/choreographer/widgets/it_feedback_card.dart +++ b/lib/pangea/choreographer/widgets/it_feedback_card.dart @@ -9,7 +9,7 @@ import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_repo.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_response_model.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import '../../../widgets/matrix.dart'; import '../../bot/utils/bot_style.dart'; import '../../common/controllers/pangea_controller.dart'; @@ -53,27 +53,17 @@ class ITFeedbackCardController extends State { isLoadingFeedback = true; }); - try { - res = await FullTextTranslationRepo.translate( - accessToken: controller.userController.accessToken, - request: widget.req, - ); - } catch (e, s) { - error = e; - ErrorHandler.logError( - e: e, - s: s, - data: { - "req": widget.req.toJson(), - "choiceFeedback": widget.choiceFeedback, - }, - ); - } finally { - if (mounted) { - setState(() { - isLoadingFeedback = false; - }); - } + final result = await FullTextTranslationRepo.get( + controller.userController.accessToken, + widget.req, + ); + res = result.result; + + if (result.isError) error = result.error; + if (mounted) { + setState(() { + isLoadingFeedback = false; + }); } } diff --git a/lib/pangea/events/controllers/message_data_controller.dart b/lib/pangea/events/controllers/message_data_controller.dart index 1cb1e1b74..426a7e624 100644 --- a/lib/pangea/events/controllers/message_data_controller.dart +++ b/lib/pangea/events/controllers/message_data_controller.dart @@ -7,7 +7,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_repo.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_request_model.dart'; -import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_response_model.dart'; import 'package:fluffychat/pangea/common/controllers/base_controller.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -19,6 +18,7 @@ import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart' import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; import 'package:fluffychat/pangea/events/repo/tokens_repo.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; // TODO - make this static and take it out of the _pangeaController // will need to pass accessToken to the requests @@ -26,8 +26,6 @@ class MessageDataController extends BaseController { late PangeaController _pangeaController; final Map> _tokensCache = {}; - final Map> _representationCache = {}; - final Map> _sttTranslationCache = {}; late Timer _cacheTimer; MessageDataController(PangeaController pangeaController) { @@ -45,8 +43,6 @@ class MessageDataController extends BaseController { /// Clears the token and representation caches void _clearCache() { _tokensCache.clear(); - _representationCache.clear(); - _sttTranslationCache.clear(); debugPrint("message data cache cleared."); } @@ -116,24 +112,25 @@ class MessageDataController extends BaseController { Future getPangeaRepresentation({ required FullTextTranslationRequestModel req, required Event messageEvent, - }) async { - return _representationCache[req.hashCode] ??= - _getPangeaRepresentation(req: req, messageEvent: messageEvent); - } + }) => + _getPangeaRepresentation(req: req, messageEvent: messageEvent); Future _getPangeaRepresentation({ required FullTextTranslationRequestModel req, required Event messageEvent, }) async { - final FullTextTranslationResponseModel res = - await FullTextTranslationRepo.translate( - accessToken: _pangeaController.userController.accessToken, - request: req, + final res = await FullTextTranslationRepo.get( + _pangeaController.userController.accessToken, + req, ); + if (res.isError) { + throw res.error!; + } + final rep = PangeaRepresentation( langCode: req.tgtLang, - text: res.bestTranslation, + text: res.result!.bestTranslation, originalSent: false, originalWritten: false, ); @@ -161,19 +158,22 @@ class MessageDataController extends BaseController { required PangeaMessageEvent messageEvent, bool originalSent = false, }) async { - final FullTextTranslationResponseModel res = - await FullTextTranslationRepo.translate( - accessToken: _pangeaController.userController.accessToken, - request: req, + final res = await FullTextTranslationRepo.get( + _pangeaController.userController.accessToken, + req, ); + if (res.isError) { + return null; + } + if (originalSent && messageEvent.originalSent != null) { originalSent = false; } final rep = PangeaRepresentation( langCode: req.tgtLang, - text: res.bestTranslation, + text: res.result!.bestTranslation, originalSent: originalSent, originalWritten: false, ); @@ -230,27 +230,28 @@ class MessageDataController extends BaseController { required FullTextTranslationRequestModel req, required Room? room, }) => - _sttTranslationCache[req.hashCode] ??= _getSttTranslation( + _getSttTranslation( repEventId: repEventId, req: req, room: room, - ).catchError((e, s) { - _sttTranslationCache.remove(req.hashCode); - return Future.error(e, s); - }); + ); Future _getSttTranslation({ required String? repEventId, required FullTextTranslationRequestModel req, required Room? room, }) async { - final res = await FullTextTranslationRepo.translate( - accessToken: _pangeaController.userController.accessToken, - request: req, + final res = await FullTextTranslationRepo.get( + _pangeaController.userController.accessToken, + req, ); + if (res.isError) { + throw res.error!; + } + final translation = SttTranslationModel( - translation: res.bestTranslation, + translation: res.result!.bestTranslation, langCode: req.tgtLang, );