refactor: move caching logic into repos

This commit is contained in:
ggurdin 2025-10-24 15:33:34 -04:00
parent 2637308891
commit f020e02b20
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
7 changed files with 342 additions and 264 deletions

View file

@ -609,7 +609,7 @@ class Choreographer {
choreoRecord = null;
translatedText = null;
itController.clear();
igc.dispose();
igc.clear();
_resetDebounceTimer();
}

View file

@ -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<IGCTextData> 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<int, _IGCTextDataCacheItem> _igcTextDataCache = {};
final Map<int, _IgnoredMatchCacheItem> _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<void> 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<PangeaMatch> 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<PangeaMatch> 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();
}
}

View file

@ -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 {

View file

@ -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<FullTextTranslationResponseModel> response;
final DateTime timestamp;
_TranslateCacheItem({
required this.response,
required this.timestamp,
});
}
class FullTextTranslationRepo {
static final Map<String, FullTextTranslationResponseModel> _cache = {};
static Timer? _cacheTimer;
static final Map<String, _TranslateCacheItem> _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<FullTextTranslationResponseModel> 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<Result<FullTextTranslationResponseModel>> 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<FullTextTranslationResponseModel> _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<Result<FullTextTranslationResponseModel>> _getResult(
FullTextTranslationRequestModel request,
Future<FullTextTranslationResponseModel> 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<FullTextTranslationResponseModel>? _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<FullTextTranslationResponseModel> response,
) =>
_cache[request.hashCode.toString()] = _TranslateCacheItem(
response: response,
timestamp: DateTime.now(),
);
}

View file

@ -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<IGCTextData> 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<IGCTextData> getIGC(
static final Map<String, _IgcCacheItem> _igcCache = {};
static final Map<String, _IgnoredMatchCacheItem> _ignoredMatchCache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<IGCTextData>> 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<IGCTextData> _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<String, dynamic> json =
jsonDecode(utf8.decode(res.bodyBytes).toString());
final IGCTextData response = IGCTextData.fromJson(json);
return IGCTextData.fromJson(json);
}
return response;
static Future<Result<IGCTextData>> _getResult(
IGCRequestModel request,
Future<IGCTextData> 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<IGCTextData>? _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<IGCTextData> 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;
}
}

View file

@ -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<ITFeedbackCard> {
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;
});
}
}

View file

@ -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<int, Future<TokensResponseModel>> _tokensCache = {};
final Map<int, Future<PangeaRepresentation>> _representationCache = {};
final Map<int, Future<SttTranslationModel>> _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<PangeaRepresentation> getPangeaRepresentation({
required FullTextTranslationRequestModel req,
required Event messageEvent,
}) async {
return _representationCache[req.hashCode] ??=
_getPangeaRepresentation(req: req, messageEvent: messageEvent);
}
}) =>
_getPangeaRepresentation(req: req, messageEvent: messageEvent);
Future<PangeaRepresentation> _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<SttTranslationModel>.error(e, s);
});
);
Future<SttTranslationModel> _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,
);