reorganize pangea rep / pangea message event files

This commit is contained in:
ggurdin 2025-12-03 15:22:45 -05:00
parent 19d11994d6
commit 9cb155fcf1
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
9 changed files with 609 additions and 752 deletions

View file

@ -2149,7 +2149,7 @@ class ChatController extends State<ChatPageWithRoom>
ownMessage: true,
);
final stt = await messageEvent.getSpeechToText(
final stt = await messageEvent.requestSpeechToText(
MatrixState.pangeaController.languageController.userL1?.langCodeShort ??
LanguageKeys.unknownLanguage,
MatrixState.pangeaController.languageController.userL2?.langCodeShort ??

View file

@ -45,7 +45,7 @@ class ActivitySummaryAnalyticsModel {
}
void addMessageConstructs(PangeaMessageEvent event) {
final uses = event.originalSent?.vocabAndMorphUses();
final uses = event.originalSent?.vocabAndMorphUses;
if (uses == null || uses.isEmpty) return;
addConstructs(event.senderId, uses);
}

View file

@ -4,13 +4,14 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/events/repo/language_detection_repo.dart';
@ -72,12 +73,11 @@ class PangeaMessageEvent {
bool get isAudioMessage => _event.messageType == MessageTypes.Audio;
String? get mimetype {
if (!isAudioMessage) return null;
final Map<String, dynamic>? info = _event.content.tryGetMap("info");
if (info == null) return null;
return info["mime_type"] ?? info["mimetype"];
}
String? get _l2Code =>
MatrixState.pangeaController.languageController.activeL2Code();
String? get _l1Code =>
MatrixState.pangeaController.languageController.userL1?.langCode;
Event? _latestEditCache;
Event get _latestEdit => _latestEditCache ??= _event
@ -92,131 +92,6 @@ class PangeaMessageEvent {
.firstOrNull ??
_event;
void updateLatestEdit() {
_latestEditCache = null;
_representations = null;
}
Future<PangeaAudioFile> getMatrixAudioFile(
String langCode,
) async {
final RepresentationEvent? rep = representationByLanguage(langCode);
final tokensResp = await rep?.tokensGlobal(
senderId,
originServerTs,
);
final TextToSpeechRequest params = TextToSpeechRequest(
text: rep?.content.text ?? body,
tokens: tokensResp?.result?.map((t) => t.text).toList() ?? [],
langCode: langCode,
userL1: l1Code ?? LanguageKeys.unknownLanguage,
userL2: l2Code ?? LanguageKeys.unknownLanguage,
);
final TextToSpeechResponse response =
await MatrixState.pangeaController.textToSpeech.get(
params,
);
final audioBytes = base64.decode(response.audioContent);
final eventIdParam = _event.eventId;
final fileName =
"audio_for_${eventIdParam}_$langCode.${response.fileExtension}";
final file = PangeaAudioFile(
bytes: audioBytes,
name: fileName,
mimeType: response.mimeType,
duration: response.durationMillis,
waveform: response.waveform,
tokens: response.ttsTokens,
);
sendAudioEvent(file, response, rep?.text ?? body, langCode);
return file;
}
Future<Event?> sendAudioEvent(
PangeaAudioFile file,
TextToSpeechResponse response,
String text,
String langCode,
) async {
final String? eventId = await room.sendFileEvent(
file,
inReplyTo: _event,
extraContent: {
'info': {
...file.info,
'duration': response.durationMillis,
},
'org.matrix.msc3245.voice': {},
'org.matrix.msc1767.audio': {
'duration': response.durationMillis,
'waveform': response.waveform,
},
ModelKey.transcription:
response.toPangeaAudioEventData(text, langCode).toJson(),
},
);
debugPrint("eventId in getTextToSpeechGlobal $eventId");
final Event? audioEvent =
eventId != null ? await room.getEventById(eventId) : null;
if (audioEvent == null) {
return null;
}
allAudio.add(audioEvent);
return audioEvent;
}
Event? getTextToSpeechLocal(String langCode, String text) {
return allAudio.firstWhereOrNull(
(event) {
try {
// Safely access
final dataMap = event.content.tryGetMap(ModelKey.transcription);
if (dataMap == null) {
return false;
}
// old text to speech content will not have TTSToken data
// we want to disregard them and just generate new ones
// for that, we'll return false if 'tokens' are null
// while in-development, we'll pause here to inspect
// debugger can be removed after we're sure it's working
if (dataMap['tokens'] == null) {
// events before today will definitely not have the tokens
debugger(
when: kDebugMode &&
event.originServerTs.isAfter(DateTime(2024, 10, 16)),
);
return false;
}
final PangeaAudioEventData audioData =
PangeaAudioEventData.fromJson(dataMap as dynamic);
// Check if both language code and text match
return audioData.langCode == langCode && audioData.text == text;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: {},
m: "error parsing data in getTextToSpeechLocal",
);
return false;
}
},
);
}
// get audio events that are related to this event
Set<Event> get allAudio => _latestEdit
.aggregatedEvents(
@ -230,159 +105,25 @@ class PangeaMessageEvent {
null;
}).toSet();
SpeechToTextResponseModel? getSpeechToTextLocal() {
final rawBotTranscription =
event.content.tryGetMap(ModelKey.botTranscription);
if (rawBotTranscription != null) {
try {
return SpeechToTextResponseModel.fromJson(
Map<String, dynamic>.from(rawBotTranscription),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"event": _event.toJson(),
},
m: "error parsing botTranscription",
);
return null;
}
}
return representations
.firstWhereOrNull(
(element) => element.content.speechToText != null,
)
?.content
.speechToText;
}
Future<SpeechToTextResponseModel> getSpeechToText(
String l1Code,
String l2Code,
) async {
if (!isAudioMessage) {
throw 'Calling getSpeechToText on non-audio message';
}
final rawBotTranscription =
event.content.tryGetMap(ModelKey.botTranscription);
if (rawBotTranscription != null) {
final SpeechToTextResponseModel botTranscription =
SpeechToTextResponseModel.fromJson(
Map<String, dynamic>.from(rawBotTranscription),
);
_representations ??= [];
_representations!.add(
RepresentationEvent(
timeline: timeline,
parentMessageEvent: _event,
content: PangeaRepresentation(
langCode: botTranscription.langCode,
text: botTranscription.transcript.text,
originalSent: false,
originalWritten: false,
speechToText: botTranscription,
),
),
);
return botTranscription;
}
final SpeechToTextResponseModel? speechToTextLocal = representations
.firstWhereOrNull(
(element) => element.content.speechToText != null,
)
?.content
.speechToText;
if (speechToTextLocal != null) {
return speechToTextLocal;
}
final matrixFile = await _event.downloadAndDecryptAttachment();
final result = await SpeechToTextRepo.get(
MatrixState.pangeaController.userController.accessToken,
SpeechToTextRequestModel(
audioContent: matrixFile.bytes,
audioEvent: _event,
config: SpeechToTextAudioConfigModel(
encoding: mimeTypeToAudioEncoding(matrixFile.mimeType),
sampleRateHertz: 22050,
userL1: l1Code,
userL2: l2Code,
),
),
);
if (result.error != null) {
throw Exception(
"Error getting speech to text: ${result.error}",
);
}
final SpeechToTextResponseModel response = result.result!;
_representations?.add(
RepresentationEvent(
timeline: timeline,
parentMessageEvent: _event,
content: PangeaRepresentation(
langCode: response.langCode,
text: response.transcript.text,
originalSent: false,
originalWritten: false,
speechToText: response,
),
),
);
return response;
}
Future<String> sttTranslationByLanguageGlobal({
required String langCode,
required String l1Code,
required String l2Code,
}) async {
if (!representations.any(
(element) => element.content.speechToText != null,
)) {
await getSpeechToText(l1Code, l2Code);
}
final rep = representations.firstWhereOrNull(
(element) => element.content.speechToText != null,
);
if (rep == null) {
throw Exception("No speech to text representation found");
}
final resp = await rep.getSttTranslation(userL1: l1Code, userL2: l2Code);
return resp.translation;
}
PangeaMessageTokens? _tokensSafe(Map<String, dynamic>? content) {
try {
if (content == null) return null;
return PangeaMessageTokens.fromJson(content);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: content ?? {},
m: "error parsing tokensSent",
);
return null;
}
}
List<RepresentationEvent> get _repEvents => _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.representation,
)
.map(
(e) => RepresentationEvent(
event: e,
parentMessageEvent: _event,
timeline: timeline,
),
)
.sorted(
(a, b) {
if (a.event == null) return -1;
if (b.event == null) return 1;
return b.event!.originServerTs.compareTo(a.event!.originServerTs);
},
).toList();
ChoreoRecordModel? get _embeddedChoreo {
try {
@ -402,6 +143,22 @@ class PangeaMessageEvent {
}
}
PangeaMessageTokens? _tokensSafe(Map<String, dynamic>? content) {
try {
if (content == null) return null;
return PangeaMessageTokens.fromJson(content);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: content ?? {},
m: "error parsing tokensSent",
);
return null;
}
}
List<RepresentationEvent>? _representations;
List<RepresentationEvent> get representations {
if (_representations != null) return _representations!;
@ -419,18 +176,6 @@ class PangeaMessageEvent {
choreo: _embeddedChoreo,
timeline: timeline,
);
if (_latestEdit.content[ModelKey.choreoRecord] == null) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "originalSent created without _event or _choreo",
data: {
"eventId": _latestEdit.eventId,
"room": _latestEdit.room.id,
"sender": _latestEdit.senderId,
},
),
);
}
// If originalSent has no tokens, there is not way to generate a tokens event
// and send it as a related event, since original sent has not eventID to set
@ -480,155 +225,10 @@ class PangeaMessageEvent {
}
}
_representations!.addAll(
_latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.representation,
)
.map(
(e) => RepresentationEvent(
event: e,
parentMessageEvent: _event,
timeline: timeline,
),
)
.sorted(
(a, b) {
//TODO - test with edited events to make sure this is working
if (a.event == null) return -1;
if (b.event == null) return 1;
return b.event!.originServerTs.compareTo(a.event!.originServerTs);
},
).toList(),
);
_representations!.addAll(_repEvents);
return _representations!;
}
RepresentationEvent? representationByLanguage(
String langCode, {
bool Function(RepresentationEvent)? filter,
}) =>
representations.firstWhereOrNull(
(element) =>
element.langCode.split("-")[0] == langCode.split("-")[0] &&
(filter?.call(element) ?? true),
);
Future<String?> representationByDetectedLanguage() async {
LanguageDetectionResponse? resp;
try {
resp = await LanguageDetectionRepo.get(
MatrixState.pangeaController.userController.accessToken,
request: LanguageDetectionRequest(
text: _latestEdit.body,
senderl1: l1Code,
senderl2: l2Code,
),
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"event": _event.toJson(),
},
);
return null;
}
final langCode = resp.detections.firstOrNull?.langCode;
if (langCode == null) return null;
if (langCode == originalSent?.langCode) {
return originalSent?.event?.eventId;
}
// clear representations cache so the new representation event can be added when next requested
_representations = null;
final res = await FullTextTranslationRepo.get(
MatrixState.pangeaController.userController.accessToken,
FullTextTranslationRequestModel(
text: originalSent?.content.text ?? _latestEdit.body,
srcLang: originalSent?.langCode,
tgtLang: langCode,
userL2: l2Code ?? LanguageKeys.unknownLanguage,
userL1: l1Code ?? LanguageKeys.unknownLanguage,
),
);
if (res.isError) return null;
final repEvent = await room.sendPangeaEvent(
content: PangeaRepresentation(
langCode: langCode,
text: res.result!,
originalSent: originalSent == null,
originalWritten: false,
).toJson(),
parentEventId: eventId,
type: PangeaEventTypes.representation,
);
return repEvent?.eventId;
}
Future<String> l1Respresentation() async {
if (l1Code == null || l2Code == null) {
throw Exception("Missing language codes");
}
final includedIT =
(originalSent?.choreo?.endedWithIT(originalSent!.text) ?? false) &&
!(originalSent?.choreo?.includedIGC ?? true);
RepresentationEvent? rep;
if (!includedIT) {
// if the message didn't go through translation, get any l1 rep
rep = representationByLanguage(l1Code!);
} else {
// if the message went through translation, get the non-original
// l1 rep since originalWritten could contain some l2 words
// (https://github.com/pangeachat/client/issues/3591)
rep = representationByLanguage(
l1Code!,
filter: (rep) => !rep.content.originalWritten,
);
}
if (rep != null) return rep.content.text;
final String srcLang = includedIT
? (originalWritten?.langCode ?? l1Code!)
: (originalSent?.langCode ?? l2Code!);
// clear representations cache so the new representation event can be added when next requested
_representations = null;
final resp = await FullTextTranslationRepo.get(
MatrixState.pangeaController.userController.accessToken,
FullTextTranslationRequestModel(
text: includedIT ? originalWrittenContent : messageDisplayText,
srcLang: srcLang,
tgtLang: l1Code!,
userL2: l2Code!,
userL1: l1Code!,
),
);
if (resp.isError) throw resp.error!;
room.sendPangeaEvent(
content: PangeaRepresentation(
langCode: l1Code!,
text: resp.result!,
originalSent: false,
originalWritten: false,
).toJson(),
parentEventId: eventId,
type: PangeaEventTypes.representation,
);
return resp.result!;
}
RepresentationEvent? get originalSent => representations
.firstWhereOrNull((element) => element.content.originalSent);
@ -646,12 +246,6 @@ class PangeaMessageEvent {
return written ?? body;
}
String? get l2Code =>
MatrixState.pangeaController.languageController.activeL2Code();
String? get l1Code =>
MatrixState.pangeaController.languageController.userL1?.langCode;
String get messageDisplayLangCode {
if (isAudioMessage) {
final stt = getSpeechToTextLocal();
@ -665,7 +259,7 @@ class PangeaMessageEvent {
final String? originalLangCode = originalSent?.langCode;
final String? langCode = immersionMode ? l2Code : originalLangCode;
final String? langCode = immersionMode ? _l2Code : originalLangCode;
return langCode ?? LanguageKeys.unknownLanguage;
}
@ -681,4 +275,319 @@ class PangeaMessageEvent {
PLanguageStore.rtlLanguageCodes.contains(messageDisplayLangCode)
? TextDirection.rtl
: TextDirection.ltr;
void updateLatestEdit() {
_latestEditCache = null;
_representations = null;
}
RepresentationEvent? representationByLanguage(
String langCode, {
bool Function(RepresentationEvent)? filter,
}) =>
representations.firstWhereOrNull(
(element) =>
element.langCode.split("-")[0] == langCode.split("-")[0] &&
(filter?.call(element) ?? true),
);
Event? getTextToSpeechLocal(String langCode, String text) {
for (final audio in allAudio) {
final dataMap = audio.content.tryGetMap(ModelKey.transcription);
if (dataMap == null || !dataMap.containsKey('tokens')) continue;
try {
final PangeaAudioEventData audioData = PangeaAudioEventData.fromJson(
dataMap as dynamic,
);
if (audioData.langCode == langCode && audioData.text == text) {
return audio;
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: {
"event": audio.toJson(),
},
m: "error parsing data in getTextToSpeechLocal",
);
}
}
return null;
}
SpeechToTextResponseModel? getSpeechToTextLocal() {
final rawBotTranscription =
event.content.tryGetMap(ModelKey.botTranscription);
if (rawBotTranscription != null) {
try {
return SpeechToTextResponseModel.fromJson(
Map<String, dynamic>.from(rawBotTranscription),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"event": _event.toJson(),
},
m: "error parsing botTranscription",
);
return null;
}
}
return representations
.firstWhereOrNull(
(element) => element.content.speechToText != null,
)
?.content
.speechToText;
}
Future<PangeaAudioFile> requestTextToSpeech(
String langCode,
) async {
final local = getTextToSpeechLocal(langCode, messageDisplayText);
if (local != null) {
final file = await local.getPangeaAudioFile();
if (file != null) return file;
}
final rep = representationByLanguage(langCode);
final tokensResp = await rep?.requestTokens();
final request = TextToSpeechRequest(
text: rep?.content.text ?? body,
tokens: tokensResp?.result?.map((t) => t.text).toList() ?? [],
langCode: langCode,
userL1: _l1Code ?? LanguageKeys.unknownLanguage,
userL2: _l2Code ?? LanguageKeys.unknownLanguage,
);
final response = await MatrixState.pangeaController.textToSpeech.get(
request,
);
final audioBytes = base64.decode(response.audioContent);
final fileName =
"audio_for_${_event.eventId}_$langCode.${response.fileExtension}";
final file = PangeaAudioFile(
bytes: audioBytes,
name: fileName,
mimeType: response.mimeType,
duration: response.durationMillis,
waveform: response.waveform,
tokens: response.ttsTokens,
);
room.sendFileEvent(
file,
inReplyTo: _event,
extraContent: {
'info': {
...file.info,
'duration': response.durationMillis,
},
'org.matrix.msc3245.voice': {},
'org.matrix.msc1767.audio': {
'duration': response.durationMillis,
'waveform': response.waveform,
},
ModelKey.transcription: response
.toPangeaAudioEventData(rep?.text ?? body, langCode)
.toJson(),
},
).then((eventId) async {
final Event? audioEvent =
eventId != null ? await room.getEventById(eventId) : null;
if (audioEvent != null) {
allAudio.add(audioEvent);
}
});
return file;
}
Future<SpeechToTextResponseModel> requestSpeechToText(
String l1Code,
String l2Code,
) async {
if (!isAudioMessage) {
throw 'Calling getSpeechToText on non-audio message';
}
final speechToTextLocal = getSpeechToTextLocal();
if (speechToTextLocal != null) {
return speechToTextLocal;
}
final matrixFile = await _event.downloadAndDecryptAttachment();
final result = await SpeechToTextRepo.get(
MatrixState.pangeaController.userController.accessToken,
SpeechToTextRequestModel(
audioContent: matrixFile.bytes,
audioEvent: _event,
config: SpeechToTextAudioConfigModel(
encoding: mimeTypeToAudioEncoding(matrixFile.mimeType),
sampleRateHertz: 22050,
userL1: l1Code,
userL2: l2Code,
),
),
);
if (result.error != null) {
throw Exception(
"Error getting speech to text: ${result.error}",
);
}
_representations = null;
return result.result!;
}
Future<String> requestSttTranslation({
required String langCode,
required String l1Code,
required String l2Code,
}) async {
if (!representations.any(
(element) => element.content.speechToText != null,
)) {
await requestSpeechToText(l1Code, l2Code);
}
final rep = representations.firstWhereOrNull(
(element) => element.content.speechToText != null,
);
if (rep == null) {
throw Exception("No speech to text representation found");
}
final resp = await rep.requestSttTranslation(
userL1: l1Code,
userL2: l2Code,
);
return resp.translation;
}
Future<String?> requestRepresentationByDetectedLanguage() async {
LanguageDetectionResponse? resp;
final result = await LanguageDetectionRepo.get(
MatrixState.pangeaController.userController.accessToken,
LanguageDetectionRequest(
text: _latestEdit.body,
senderl1: _l1Code,
senderl2: _l2Code,
),
);
if (result.isError) return null;
resp = result.result!;
final langCode = resp.detections.firstOrNull?.langCode;
if (langCode == null) return null;
if (langCode == originalSent?.langCode) {
return originalSent?.event?.eventId;
}
final res = await _requestRepresentation(
originalSent?.content.text ?? _latestEdit.body,
langCode,
originalSent?.langCode ?? LanguageKeys.unknownLanguage,
originalSent: originalSent == null,
);
if (res.isError) return null;
return _sendRepresentationEvent(res.result!);
}
Future<String> requestRespresentationByL1() async {
if (_l1Code == null || _l2Code == null) {
throw Exception("Missing language codes");
}
final includedIT =
(originalSent?.choreo?.endedWithIT(originalSent!.text) ?? false) &&
!(originalSent?.choreo?.includedIGC ?? true);
RepresentationEvent? rep;
if (!includedIT) {
// if the message didn't go through translation, get any l1 rep
rep = representationByLanguage(_l1Code!);
} else {
// if the message went through translation, get the non-original
// l1 rep since originalWritten could contain some l2 words
// (https://github.com/pangeachat/client/issues/3591)
rep = representationByLanguage(
_l1Code!,
filter: (rep) => !rep.content.originalWritten,
);
}
if (rep != null) return rep.content.text;
final String srcLang = includedIT
? (originalWritten?.langCode ?? _l1Code!)
: (originalSent?.langCode ?? _l2Code!);
final resp = await _requestRepresentation(
includedIT ? originalWrittenContent : messageDisplayText,
_l1Code!,
srcLang,
);
if (resp.isError) throw resp.error!;
_sendRepresentationEvent(resp.result!);
return resp.result!.text;
}
Future<Result<PangeaRepresentation>> _requestRepresentation(
String text,
String targetLang,
String sourceLang, {
bool originalSent = false,
}) async {
_representations = null;
final res = await FullTextTranslationRepo.get(
MatrixState.pangeaController.userController.accessToken,
FullTextTranslationRequestModel(
text: text,
srcLang: sourceLang,
tgtLang: targetLang,
userL2: _l2Code ?? LanguageKeys.unknownLanguage,
userL1: _l1Code ?? LanguageKeys.unknownLanguage,
),
);
return res.isError
? Result.error(res.error!)
: Result.value(
PangeaRepresentation(
langCode: targetLang,
text: res.result!,
originalSent: originalSent,
originalWritten: false,
),
);
}
Future<String?> _sendRepresentationEvent(
PangeaRepresentation representation,
) async {
final repEvent = await room.sendPangeaEvent(
content: representation.toJson(),
parentEventId: eventId,
type: PangeaEventTypes.representation,
);
return repEvent?.eventId;
}
}

View file

@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
@ -25,9 +24,6 @@ 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/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/translation/full_text_translation_repo.dart';
import 'package:fluffychat/pangea/translation/full_text_translation_request_model.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -62,6 +58,33 @@ class RepresentationEvent {
Event? get event => _event;
String get text => content.text;
String get langCode => content.langCode;
List<LanguageDetectionModel>? get detections => _tokens?.detections;
Set<Event> get tokenEvents =>
_event?.aggregatedEvents(
timeline,
PangeaEventTypes.tokens,
) ??
{};
Set<Event> get sttEvents =>
_event?.aggregatedEvents(
timeline,
PangeaEventTypes.sttTranslation,
) ??
{};
Set<Event> get choreoEvents =>
_event?.aggregatedEvents(
timeline,
PangeaEventTypes.choreoRecord,
) ??
{};
// Note: in the case where the event is the originalSent or originalWritten event,
// the content will be set on initialization by the PangeaMessageEvent
// Otherwise, the content will be fetched from the event where it is stored in content[type]
@ -71,50 +94,134 @@ class RepresentationEvent {
return _content!;
}
String get text => content.text;
String get langCode => content.langCode;
bool get botAuthored =>
content.originalSent == false && content.originalWritten == false;
List<LanguageDetectionModel>? get detections => _tokens?.detections;
List<PangeaToken>? get tokens {
if (_tokens != null) return _tokens!.tokens;
if (_event == null) return null;
final Set<Event> tokenEvents = _event?.aggregatedEvents(
timeline,
PangeaEventTypes.tokens,
) ??
{};
if (tokenEvents.isEmpty) return null;
_tokens = tokenEvents.last.getPangeaContent<PangeaMessageTokens>();
return _tokens?.tokens;
}
Future<Result<List<PangeaToken>>> tokensGlobal(
String senderID,
DateTime timestamp,
) async {
if (tokens != null) return Result.value(tokens!);
ChoreoRecordModel? get choreo {
if (_choreo != null) return _choreo;
if (_event == null && timestamp.isAfter(DateTime(2024, 9, 25))) {
if (_event == null) {
Sentry.addBreadcrumb(
Breadcrumb(
message:
'representation with no _event and no tokens got tokens directly. This means an original_sent with no tokens. This should not happen in messages sent after September 25',
data: {
'content': content.toJson(),
'event': _event?.toJson(),
'timestamp': timestamp.toIso8601String(),
'senderID': senderID,
},
message: "_event and _choreo both null",
),
);
return null;
}
if (choreoEvents.isEmpty) return null;
if (choreoEvents.length > 1) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: 'should not have more than one choreoEvent per representation ${_event?.eventId}',
s: StackTrace.current,
data: {"event": _event?.toJson()},
);
}
return ChoreoEvent(event: choreoEvents.first).content;
}
List<SttTranslationModel> get sttTranslations {
if (content.speechToText == null) return [];
if (_event == null) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "_event and _sttTranslations both null",
),
);
return [];
}
if (sttEvents.isEmpty) return [];
final List<SttTranslationModel> sttTranslations = [];
for (final event in sttEvents) {
try {
sttTranslations.add(
SttTranslationModel.fromJson(event.content),
);
} catch (e) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "Failed to parse STT translation",
data: {
"eventID": event.eventId,
"content": event.content,
"error": e.toString(),
},
),
);
}
}
return sttTranslations;
}
List<OneConstructUse> get vocabAndMorphUses {
if (tokens == null || tokens!.isEmpty) {
return [];
}
final metadata = ConstructUseMetaData(
roomId: parentMessageEvent.room.id,
timeStamp: parentMessageEvent.originServerTs,
eventId: parentMessageEvent.eventId,
);
return content.vocabAndMorphUses(
tokens: tokens!,
metadata: metadata,
choreo: choreo,
);
}
/// Finds the closest non-punctuation token to the given token.
PangeaToken? getClosestNonPunctToken(PangeaToken token) {
// If it's not punctuation, it's already the closest.
if (token.pos != "PUNCT") return token;
final list = tokens;
if (list == null) return null;
final index = list.indexOf(token);
if (index == -1) return null;
PangeaToken? left;
PangeaToken? right;
// Scan left
for (int i = index - 1; i >= 0; i--) {
if (list[i].pos != "PUNCT") {
left = list[i];
break;
}
}
// Scan right
for (int i = index + 1; i < list.length; i++) {
if (list[i].pos != "PUNCT") {
right = list[i];
break;
}
}
if (left == null) return right;
if (right == null) return left;
// Choose the nearest by distance
final leftDistance = token.start - left.end;
final rightDistance = right.start - token.end;
return leftDistance < rightDistance ? left : right;
}
Future<Result<List<PangeaToken>>> requestTokens() async {
if (tokens != null) return Result.value(tokens!);
final res = await TokensRepo.get(
MatrixState.pangeaController.userController.accessToken,
TokensRequestModel(
@ -140,90 +247,12 @@ class RepresentationEvent {
);
}
if (res.isError) {
return Result.error(res.error!);
} else {
return Result.value(res.result!.tokens);
}
return res.isError
? Result.error(res.error!)
: Result.value(res.result!.tokens);
}
Future<void> sendTokensEvent(
String repEventID,
Room room,
String userl1,
String userl2,
) async {
if (tokens != null) return;
if (_event == null) {
ErrorHandler.logError(
e: "Called getTokensEvent with no _event",
data: {},
);
return;
}
final resp = await TokensRepo.get(
MatrixState.pangeaController.userController.accessToken,
TokensRequestModel(
fullText: text,
langCode: langCode,
senderL1: userl1,
senderL2: userl2,
),
);
if (resp.isError) return;
room.sendPangeaEvent(
content: PangeaMessageTokens(
tokens: resp.result!.tokens,
detections: resp.result!.detections,
).toJson(),
parentEventId: _event!.eventId,
type: PangeaEventTypes.tokens,
);
}
List<SttTranslationModel> get sttTranslations {
if (content.speechToText == null) return [];
if (_event == null) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "_event and _sttTranslations both null",
),
);
return [];
}
final Set<Event> sttEvents = _event!.aggregatedEvents(
timeline,
PangeaEventTypes.sttTranslation,
);
if (sttEvents.isEmpty) return [];
final List<SttTranslationModel> sttTranslations = [];
for (final event in sttEvents) {
try {
sttTranslations.add(
SttTranslationModel.fromJson(event.content),
);
} catch (e) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "Failed to parse STT translation",
data: {
"eventID": event.eventId,
"content": event.content,
"error": e.toString(),
},
),
);
}
}
return sttTranslations;
}
Future<SttTranslationModel> getSttTranslation({
Future<SttTranslationModel> requestSttTranslation({
required String userL1,
required String userL2,
}) async {
@ -263,137 +292,4 @@ class RepresentationEvent {
return translation;
}
ChoreoRecordModel? get choreo {
if (_choreo != null) return _choreo;
if (_event == null) {
Sentry.addBreadcrumb(
Breadcrumb(
message: "_event and _choreo both null",
),
);
return null;
}
final Set<Event> choreoMatrixEvents =
_event?.aggregatedEvents(timeline, PangeaEventTypes.choreoRecord) ?? {};
if (choreoMatrixEvents.isEmpty) return null;
if (choreoMatrixEvents.length > 1) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: 'should not have more than one choreoEvent per representation ${_event?.eventId}',
s: StackTrace.current,
data: {"event": _event?.toJson()},
);
}
_choreo = ChoreoEvent(event: choreoMatrixEvents.first).content;
return _choreo;
}
String? formatBody() {
return markdown(content.text);
}
/// Finds the closest non-punctuation token to the given token.
///
/// This method checks if the provided token is a punctuation token. If it is not,
/// it returns the token itself. If the token is a punctuation token, it searches
/// through the list of tokens to find the closest non-punctuation token either to
/// the left or right of the given token.
///
/// If both left and right non-punctuation tokens are found, it returns the one
/// that is closest to the given token. If only one of them is found, it returns
/// that token. If no non-punctuation tokens are found, it returns null.
///
/// - Parameters:
/// - token: The token for which to find the closest non-punctuation token.
///
/// - Returns: The closest non-punctuation token, or null if no such token exists.
PangeaToken? getClosestNonPunctToken(PangeaToken token) {
if (token.pos != "PUNCT") return token;
if (tokens == null) return null;
final index = tokens!.indexOf(token);
if (index > -1) {
final leftTokens = tokens!.sublist(0, index);
final rightTokens = tokens!.sublist(index + 1);
final leftMostToken = leftTokens.lastWhereOrNull(
(element) => element.pos != "PUNCT",
);
final rightMostToken = rightTokens.firstWhereOrNull(
(element) => element.pos != "PUNCT",
);
if (leftMostToken != null && rightMostToken != null) {
final leftDistance = token.start - leftMostToken.end;
final rightDistance = rightMostToken.start - token.end;
return leftDistance < rightDistance ? leftMostToken : rightMostToken;
} else if (leftMostToken != null) {
return leftMostToken;
} else if (rightMostToken != null) {
return rightMostToken;
}
}
return null;
}
List<PangeaToken> get tokensToSave =>
tokens?.where((token) => token.lemma.saveVocab).toList() ?? [];
// List<ConstructIdentifier> get allTokenMorphsToConstructIdentifiers => tokens?.map((t) => t.morphConstructIds).toList() ??
// [];
/// get allTokenMorphsToConstructIdentifiers
Set<MorphFeaturesEnum> get morphFeatureSetToPractice =>
MorphFeaturesEnum.values.where((feature) {
// pos is always included
if (feature == MorphFeaturesEnum.Pos) {
return true;
}
return tokens?.any((token) => token.morph.containsKey(feature)) ??
false;
}).toSet();
Set<PartOfSpeechEnum> posSetToPractice(ActivityTypeEnum a) =>
PartOfSpeechEnum.values.where((pos) {
// some pos are not eligible for practice at all
if (!pos.eligibleForPractice(a)) {
return false;
}
return tokens?.any(
(token) => token.pos.toLowerCase() == pos.name.toLowerCase(),
) ??
false;
}).toSet();
List<String> tagsByFeature(MorphFeaturesEnum feature) {
return tokens
?.where((t) => t.morph.containsKey(feature))
.map((t) => t.morph[feature])
.cast<String>()
.toList() ??
[];
}
List<OneConstructUse> vocabAndMorphUses() {
if (tokens == null || tokens!.isEmpty) {
return [];
}
final metadata = ConstructUseMetaData(
roomId: parentMessageEvent.room.id,
timeStamp: parentMessageEvent.originServerTs,
eventId: parentMessageEvent.eventId,
);
return content.vocabAndMorphUses(
tokens: tokens!,
metadata: metadata,
choreo: choreo,
);
}
}

View file

@ -1,18 +1,47 @@
import 'dart:convert';
import 'package:async/async.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/events/repo/language_detection_request.dart';
import 'package:fluffychat/pangea/events/repo/language_detection_response.dart';
class _LanguageDetectionCacheItem {
final Future<LanguageDetectionResponse> data;
final DateTime timestamp;
const _LanguageDetectionCacheItem({
required this.data,
required this.timestamp,
});
}
class LanguageDetectionRepo {
static Future<LanguageDetectionResponse> get(
String? accessToken, {
required LanguageDetectionRequest request,
}) async {
static final Map<String, _LanguageDetectionCacheItem> _cache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<LanguageDetectionResponse>> get(
String accessToken,
LanguageDetectionRequest 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<LanguageDetectionResponse> _fetch(
String accessToken,
LanguageDetectionRequest request,
) async {
final Requests req = Requests(
accessToken: accessToken,
choreoApiKey: Environment.choreoApiKey,
@ -22,12 +51,54 @@ class LanguageDetectionRepo {
body: request.toJson(),
);
final Map<String, dynamic> json =
jsonDecode(utf8.decode(res.bodyBytes).toString());
if (res.statusCode != 200) {
throw Exception(
'Failed to detect language: ${res.statusCode} ${res.reasonPhrase}',
);
}
final LanguageDetectionResponse response =
LanguageDetectionResponse.fromJson(json);
return response;
return LanguageDetectionResponse.fromJson(
jsonDecode(utf8.decode(res.bodyBytes)),
);
}
static Future<Result<LanguageDetectionResponse>> _getResult(
LanguageDetectionRequest request,
Future<LanguageDetectionResponse> 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<LanguageDetectionResponse>? _getCached(
LanguageDetectionRequest request,
) {
final cacheKeys = [..._cache.keys];
for (final key in cacheKeys) {
if (DateTime.now().difference(_cache[key]!.timestamp) >= _cacheDuration) {
_cache.remove(key);
}
}
return _cache[request.hashCode.toString()]?.data;
}
static void _setCached(
LanguageDetectionRequest request,
Future<LanguageDetectionResponse> response,
) =>
_cache[request.hashCode.toString()] = _LanguageDetectionCacheItem(
data: response,
timestamp: DateTime.now(),
);
}

View file

@ -16,4 +16,16 @@ class LanguageDetectionRequest {
'sender_l2': senderl2,
};
}
@override
int get hashCode => text.hashCode ^ senderl1.hashCode ^ senderl2.hashCode;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is LanguageDetectionRequest &&
other.text == text &&
other.senderl1 == senderl1 &&
other.senderl2 == senderl2;
}
}

View file

@ -10,7 +10,6 @@ import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
class MessageAudioCard extends StatefulWidget {
@ -42,19 +41,9 @@ class MessageAudioCardState extends State<MessageAudioCard> {
setState(() => _isLoading = true);
try {
final String langCode = widget.messageEvent.messageDisplayLangCode;
final Event? localEvent = widget.messageEvent.getTextToSpeechLocal(
langCode,
widget.messageEvent.messageDisplayText,
audioFile = await widget.messageEvent.requestTextToSpeech(
widget.messageEvent.messageDisplayLangCode,
);
if (localEvent != null) {
audioFile = await localEvent.getPangeaAudioFile();
} else {
audioFile = await widget.messageEvent.getMatrixAudioFile(
langCode,
);
}
debugPrint("audio file is now: $audioFile. setting starts and ends...");
if (mounted) setState(() => _isLoading = false);
} catch (e, s) {

View file

@ -112,12 +112,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
if (repEvent?.event != null) {
await repEvent!.sendTokensEvent(
repEvent.event!.eventId,
widget._event.room,
MatrixState.pangeaController.languageController.userL1!.langCode,
MatrixState.pangeaController.languageController.userL2!.langCode,
);
await repEvent!.requestTokens();
}
} catch (e, s) {
debugger(when: kDebugMode);
@ -268,7 +263,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
pangeaMessageEvent.messageDisplayRepresentation;
if (repEvent != null) return repEvent;
final eventID = await pangeaMessageEvent.representationByDetectedLanguage();
final eventID =
await pangeaMessageEvent.requestRepresentationByDetectedLanguage();
if (eventID == null) return null;
final event = await widget._event.room.getEventById(eventID);

View file

@ -8,7 +8,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
@ -19,7 +18,7 @@ class _TranscriptionLoader extends AsyncLoader<SpeechToTextResponseModel> {
_TranscriptionLoader(this.messageEvent) : super();
@override
Future<SpeechToTextResponseModel> fetch() => messageEvent.getSpeechToText(
Future<SpeechToTextResponseModel> fetch() => messageEvent.requestSpeechToText(
MatrixState.pangeaController.languageController.userL1!.langCodeShort,
MatrixState.pangeaController.languageController.userL2!.langCodeShort,
);
@ -30,7 +29,7 @@ class _STTTranslationLoader extends AsyncLoader<String> {
_STTTranslationLoader(this.messageEvent) : super();
@override
Future<String> fetch() => messageEvent.sttTranslationByLanguageGlobal(
Future<String> fetch() => messageEvent.requestSttTranslation(
langCode: MatrixState
.pangeaController.languageController.userL1!.langCodeShort,
l1Code: MatrixState
@ -45,7 +44,7 @@ class _TranslationLoader extends AsyncLoader<String> {
_TranslationLoader(this.messageEvent) : super();
@override
Future<String> fetch() => messageEvent.l1Respresentation();
Future<String> fetch() => messageEvent.requestRespresentationByL1();
}
class _AudioLoader extends AsyncLoader<(PangeaAudioFile, File?)> {
@ -54,25 +53,10 @@ class _AudioLoader extends AsyncLoader<(PangeaAudioFile, File?)> {
@override
Future<(PangeaAudioFile, File?)> fetch() async {
final String langCode = messageEvent.messageDisplayLangCode;
final Event? localEvent = messageEvent.getTextToSpeechLocal(
langCode,
messageEvent.messageDisplayText,
final audioBytes = await messageEvent.requestTextToSpeech(
messageEvent.messageDisplayLangCode,
);
PangeaAudioFile? audioBytes;
if (localEvent != null) {
audioBytes = await localEvent.getPangeaAudioFile();
} else {
audioBytes = await messageEvent.getMatrixAudioFile(
langCode,
);
}
if (audioBytes == null) {
throw Exception('Audio bytes are null');
}
File? audioFile;
if (!kIsWeb) {
final tempDir = await getTemporaryDirectory();