update lemma meaning and phonetic transcription repos

This commit is contained in:
ggurdin 2025-12-09 16:20:17 -05:00
parent c2defa9023
commit 84737dbca1
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
13 changed files with 337 additions and 88 deletions

View file

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

View file

@ -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<LemmaInfoResponse> getLemmaInfo() => LemmaInfoRepo.get(
Future<Result<LemmaInfoResponse>> getLemmaInfo() => LemmaInfoRepo.get(
MatrixState.pangeaController.userController.accessToken,
lemmaInfoRequest,
);

View file

@ -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;
}
}

View file

@ -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<Result<LemmaInfoResponse>> 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<String, _LemmaInfoCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
// Persistent storage
static final GetStorage _storage = GetStorage('lemma_storage');
/// Public entry point
static Future<Result<LemmaInfoResponse>> 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<void> 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<LemmaInfoResponse> get(LemmaInfoRequest request) async {
final cached = getCached(request);
if (cached != null) return cached;
static Future<Result<LemmaInfoResponse>> _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<LemmaInfoResponse> _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<Result<LemmaInfoResponse>>? _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<void> writeToDisk(
LemmaInfoRequest request,
Future<Result<LemmaInfoResponse>> 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;
}
}
}

View file

@ -3,12 +3,10 @@ import 'package:fluffychat/pangea/events/models/content_feedback.dart';
class LemmaInfoResponse implements JsonSerializable {
final List<String> emoji;
final String meaning;
DateTime? expireAt;
LemmaInfoResponse({
required this.emoji,
required this.meaning,
this.expireAt,
});
factory LemmaInfoResponse.fromJson(Map<String, dynamic> 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<dynamic>).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(),
};
}

View file

@ -13,7 +13,18 @@ class _LemmaMeaningLoader extends AsyncLoader<LemmaInfoResponse> {
_LemmaMeaningLoader(this.request) : super();
@override
Future<LemmaInfoResponse> fetch() => LemmaInfoRepo.get(request);
Future<LemmaInfoResponse> 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 {

View file

@ -14,9 +14,17 @@ class _TranscriptLoader extends AsyncLoader<String> {
@override
Future<String> 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;
}
}

View file

@ -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<Result<PhoneticTranscriptionResponse>> resultFuture;
final DateTime timestamp;
const _PhoneticTranscriptionCacheItem({
required this.resultFuture,
required this.timestamp,
});
}
class PhoneticTranscriptionRepo {
// In-memory cache
static final Map<String, _PhoneticTranscriptionCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
// Persistent storage
static final GetStorage _storage =
GetStorage('phonetic_transcription_storage');
static Future<Result<PhoneticTranscriptionResponse>> 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<void> 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<Result<PhoneticTranscriptionResponse>> _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<PhoneticTranscriptionResponse> _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<PhoneticTranscriptionResponse> get(
static Future<Result<PhoneticTranscriptionResponse>>? _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<void> writeToDisk(
PhoneticTranscriptionRequest request,
Future<Result<PhoneticTranscriptionResponse>> 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;
}
}
}

View file

@ -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;
}
}

View file

@ -102,14 +102,12 @@ class PhoneticTranscriptionResponse {
final Map<String, dynamic>
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<String, dynamic> json) {
@ -121,9 +119,6 @@ class PhoneticTranscriptionResponse {
phoneticTranscriptionResult: PhoneticTranscription.fromJson(
json['phonetic_transcription_result'] as Map<String, dynamic>,
),
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(),
};
}

View file

@ -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<Future<LemmaInfoResponse>> lemmaInfoFutures = missingEmojis
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<Future<Result<LemmaInfoResponse>>> lemmaInfoFutures =
missingEmojis
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<LemmaInfoResponse> lemmaInfos =
final List<Result<LemmaInfoResponse>> 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",

View file

@ -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<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
final List<Future<LemmaInfoResponse>> lemmaInfoFutures = req.targetTokens
final List<Future<Result<LemmaInfoResponse>>> lemmaInfoFutures = req
.targetTokens
.map((token) => token.vocabConstructID.getLemmaInfo())
.toList();
final List<LemmaInfoResponse> lemmaInfos =
final List<Result<LemmaInfoResponse>> lemmaInfos =
await Future.wait(lemmaInfoFutures);
if (lemmaInfos.any((result) => result.isError)) {
throw lemmaInfos.firstWhere((result) => result.isError).error!;
}
final Map<ConstructForm, List<String>> matchInfo = Map.fromIterables(
req.targetTokens.map((token) => token.vocabForm),
lemmaInfos.map((lemmaInfo) => [lemmaInfo.meaning]),
lemmaInfos.map((lemmaInfo) => [lemmaInfo.asValue!.value.meaning]),
);
return MessageActivityResponse(

View file

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