fix(analytics): emit granular IGC/IT use types instead of collapsed ga/ta (#5858)

* fix: emit granular IGC/IT use types instead of collapsed ga/ta

* formatting

* fix linter issues with deprecated use types

* fix: don't add match viewing update to choreo record, don't flatten token IGC uses into a single type

* break vocabAndMorphUses down into smaller functions

* filter viewed choreo steps when getting uses from choreo

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
wcjord 2026-03-02 12:07:49 -05:00 committed by GitHub
parent a370386016
commit f6d7bfa981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 238 additions and 115 deletions

View file

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
@ -222,6 +224,12 @@ class SpaceAnalyticsSummaryModel {
ConstructUseTypeEnum.wa,
ConstructUseTypeEnum.ga,
ConstructUseTypeEnum.ta,
ConstructUseTypeEnum.corIt,
ConstructUseTypeEnum.incIt,
ConstructUseTypeEnum.ignIt,
ConstructUseTypeEnum.corIGC,
ConstructUseTypeEnum.incIGC,
ConstructUseTypeEnum.ignIGC,
};
final List<String> morphConstructs = [];

View file

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
@ -10,9 +12,15 @@ enum ConstructUseTypeEnum {
wa,
/// produced during IGC
@Deprecated(
'Use corIGC/incIGC/ignIGC instead. Kept for backward compat with stored events.',
)
ga,
/// produced during IT
@Deprecated(
'Use corIt/incIt/ignIt instead. Kept for backward compat with stored events.',
)
ta,
/// produced in chat by user and igc was not run
@ -463,23 +471,35 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
}
}
/// Whether this use type represents direct chat production (wa, ga, ta).
/// Whether this use type represents direct chat production.
bool get isChatUse {
switch (this) {
case ConstructUseTypeEnum.wa:
case ConstructUseTypeEnum.ga:
case ConstructUseTypeEnum.ta:
case ConstructUseTypeEnum.corIt:
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.ignIt:
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.incIGC:
case ConstructUseTypeEnum.ignIGC:
return true;
default:
return false;
}
}
/// Whether this chat use involved assistance (ga = IGC, ta = IT).
/// Whether this chat use involved assistance (IGC or IT).
bool get isAssistedChatUse {
switch (this) {
case ConstructUseTypeEnum.ga:
case ConstructUseTypeEnum.ta:
case ConstructUseTypeEnum.corIt:
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.ignIt:
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.incIGC:
case ConstructUseTypeEnum.ignIGC:
return true;
default:
return false;

View file

@ -33,7 +33,12 @@ class GrammarErrorTargetGenerator {
}
final errorUses = construct.cappedUses.where(
(u) => u.useType == ConstructUseTypeEnum.ga,
(u) =>
// ignore: deprecated_member_use_from_same_package
u.useType == ConstructUseTypeEnum.ga ||
u.useType == ConstructUseTypeEnum.corIGC ||
u.useType == ConstructUseTypeEnum.ignIGC ||
u.useType == ConstructUseTypeEnum.incIGC,
);
if (errorUses.isEmpty) continue;

View file

@ -301,6 +301,14 @@ class ChoreoRecordStepModel {
.toList()
.cast<String>();
}
String? get selectedChoice {
if (itStep != null) {
return itStep!.chosenContinuance?.text;
}
return acceptedOrIgnoredMatch?.match.selectedChoice?.value;
}
}
// Example flow

View file

@ -1,12 +1,12 @@
import 'dart:math';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/choreographer/completed_it_step_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -108,12 +108,10 @@ class PangeaRepresentation {
ConstructUseMetaData? metadata,
ChoreoRecordModel? choreo,
}) {
final List<OneConstructUse> uses = [];
// missing vital info so return
if (event?.roomId == null && metadata?.roomId == null) {
// debugger(when: kDebugMode);
return uses;
return [];
}
metadata ??= ConstructUseMetaData(
@ -122,22 +120,9 @@ class PangeaRepresentation {
timeStamp: event.originServerTs,
);
// for each token, record whether selected in ga, ta, or wa
List<PangeaToken> tokensToSave = tokens
.where((token) => token.lemma.saveVocab)
.toList();
if (choreo != null && choreo.pastedStrings.isNotEmpty) {
tokensToSave = tokensToSave
.where(
(token) => !choreo.pastedStrings.any(
(pasted) => pasted.toLowerCase().contains(
token.text.content.toLowerCase(),
),
),
)
.toList();
}
final tokensToSave = _filterTokensToSave(tokens, choreo);
final List<OneConstructUse> uses = [];
if (choreo == null || choreo.choreoSteps.isEmpty) {
for (final token in tokensToSave) {
uses.addAll(
@ -148,99 +133,172 @@ class PangeaRepresentation {
),
);
}
return uses;
}
for (final token in tokensToSave) {
ChoreoRecordStepModel? tokenStep;
for (final step in choreo.choreoSteps) {
final igcMatch = step.acceptedOrIgnoredMatch;
final itStep = step.itStep;
if (itStep == null && igcMatch == null) {
continue;
}
final choices = step.choices;
if (choices == null || choices.isEmpty) {
continue;
}
final stepContainsToken = choices.any(
(choice) => choice.contains(token.text.content),
);
// if the step contains the token, and the token hasn't been assigned a step
// (or the assigned step is an IGC step, but an IT step contains the token)
// then assign the token to the step
if (stepContainsToken &&
(tokenStep == null ||
(tokenStep.itStep == null && step.itStep != null))) {
tokenStep = step;
}
}
if (tokenStep == null ||
tokenStep.acceptedOrIgnoredMatch?.status ==
PangeaMatchStatusEnum.automatic) {
// if the token wasn't found in any IT or IGC step, so it was wa
uses.addAll(
token.allUses(
ConstructUseTypeEnum.wa,
metadata,
ConstructUseTypeEnum.wa.pointValue,
),
);
continue;
}
if (tokenStep.acceptedOrIgnoredMatch != null &&
tokenStep.acceptedOrIgnoredMatch?.status !=
PangeaMatchStatusEnum.accepted) {
uses.addAll(token.allUses(ConstructUseTypeEnum.ga, metadata, 0));
continue;
}
if (tokenStep.itStep != null) {
final selectedChoices = tokenStep.itStep!.continuances
.where((choice) => choice.wasClicked)
.length;
if (selectedChoices == 0) {
ErrorHandler.logError(
e: "No selected choices for IT step",
data: {"token": token.text.content, "step": tokenStep.toJson()},
);
continue;
}
final corITPoints = ConstructUseTypeEnum.corIt.pointValue;
final incITPoints = ConstructUseTypeEnum.incIt.pointValue;
final xp = max(0, corITPoints + (incITPoints * (selectedChoices - 1)));
uses.addAll(token.allUses(ConstructUseTypeEnum.ta, metadata, xp));
} else if (tokenStep.acceptedOrIgnoredMatch!.match.choices != null) {
final selectedChoices = tokenStep.acceptedOrIgnoredMatch!.match.choices!
.where((choice) => choice.selected)
.length;
if (selectedChoices == 0) {
ErrorHandler.logError(
e: "No selected choices for IGC step",
data: {"token": token.text.content, "step": tokenStep.toJson()},
);
continue;
}
final corIGCPoints = ConstructUseTypeEnum.corIGC.pointValue;
final incIGCPoints = ConstructUseTypeEnum.incIGC.pointValue;
final xp = max(
0,
corIGCPoints + (incIGCPoints * (selectedChoices - 1)),
);
uses.addAll(token.allUses(ConstructUseTypeEnum.ga, metadata, xp));
}
final step = _getStepForToken(token, choreo);
uses.addAll(_getUsesForToken(token, metadata, step));
}
return uses;
}
List<PangeaToken> _filterTokensToSave(
List<PangeaToken> tokens,
ChoreoRecordModel? choreo,
) {
final List<PangeaToken> tokensToSave = tokens
.where((token) => token.lemma.saveVocab)
.toList();
final pastedStrings = choreo?.pastedStrings ?? <String>{};
return tokensToSave
.where(
(token) => !pastedStrings.any(
(pasted) =>
pasted.toLowerCase().contains(token.text.content.toLowerCase()),
),
)
.toList();
}
ChoreoRecordStepModel? _getStepForToken(
PangeaToken token,
ChoreoRecordModel choreo,
) {
ChoreoRecordStepModel? tokenStep;
for (final step in choreo.choreoSteps) {
final igcMatch = step.acceptedOrIgnoredMatch;
final itStep = step.itStep;
if (itStep == null &&
(igcMatch == null ||
igcMatch.status == PangeaMatchStatusEnum.viewed)) {
continue;
}
final choices = step.choices;
if (choices == null || choices.isEmpty) {
continue;
}
final stepContainsToken =
step.selectedChoice?.contains(token.text.content) == true;
// if the step contains the token, and the token hasn't been assigned a step
// (or the assigned step is an IGC step, but an IT step contains the token)
// then assign the token to the step
if (stepContainsToken &&
(tokenStep == null ||
(tokenStep.itStep == null && step.itStep != null))) {
tokenStep = step;
}
}
return tokenStep;
}
List<OneConstructUse> _getUsesForToken(
PangeaToken token,
ConstructUseMetaData metadata,
ChoreoRecordStepModel? tokenStep,
) {
if (tokenStep == null ||
tokenStep.acceptedOrIgnoredMatch?.status ==
PangeaMatchStatusEnum.automatic) {
// if the token wasn't found in any IT or IGC step, so it was wa
return token.allUses(
ConstructUseTypeEnum.wa,
metadata,
ConstructUseTypeEnum.wa.pointValue,
);
}
if (tokenStep.acceptedOrIgnoredMatch != null &&
tokenStep.acceptedOrIgnoredMatch?.status !=
PangeaMatchStatusEnum.accepted) {
return token.allUses(ConstructUseTypeEnum.ignIGC, metadata, 0);
}
if (tokenStep.itStep != null) {
return _getUsesForITToken(token, tokenStep.itStep!, metadata);
} else if (tokenStep.acceptedOrIgnoredMatch!.match.choices != null) {
return _getUsesForIGCToken(
token,
tokenStep.acceptedOrIgnoredMatch!,
metadata,
);
}
return [];
}
List<OneConstructUse> _getUsesForITToken(
PangeaToken token,
CompletedITStepModel itStep,
ConstructUseMetaData metadata,
) {
final selectedChoices = itStep.continuances.where(
(choice) => choice.wasClicked,
);
if (selectedChoices.isEmpty) {
return token.allUses(ConstructUseTypeEnum.ignIt, metadata, 0);
}
final numCorrectChoices = selectedChoices
.where((choice) => choice.gold)
.length;
final numIncorrectChoices = selectedChoices.length - numCorrectChoices;
return [
if (numCorrectChoices > 0)
...token.allUses(
ConstructUseTypeEnum.corIt,
metadata,
ConstructUseTypeEnum.corIt.pointValue * numCorrectChoices,
),
if (numIncorrectChoices > 0)
...token.allUses(
ConstructUseTypeEnum.incIt,
metadata,
ConstructUseTypeEnum.incIt.pointValue * numIncorrectChoices,
),
];
}
List<OneConstructUse> _getUsesForIGCToken(
PangeaToken token,
PangeaMatch match,
ConstructUseMetaData metadata,
) {
final selectedChoices = match.match.choices!.where(
(choice) => choice.selected,
);
if (selectedChoices.isEmpty) {
return token.allUses(ConstructUseTypeEnum.ignIGC, metadata, 0);
}
final numCorrectChoices = selectedChoices
.where((choice) => choice.type.isSuggestion)
.length;
final numIncorrectChoices = selectedChoices.length - numCorrectChoices;
return [
if (numCorrectChoices > 0)
...token.allUses(
ConstructUseTypeEnum.corIGC,
metadata,
ConstructUseTypeEnum.corIGC.pointValue * numCorrectChoices,
),
if (numIncorrectChoices > 0)
...token.allUses(
ConstructUseTypeEnum.incIGC,
metadata,
ConstructUseTypeEnum.incIGC.pointValue * numIncorrectChoices,
),
];
}
}

View file

@ -1,3 +1,5 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter_test/flutter_test.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
@ -54,18 +56,30 @@ ConstructIdentifier _makeId({String lemma = 'test', String category = 'verb'}) {
void main() {
group('ConstructUseTypeEnum boolean getters', () {
test('isChatUse is true for wa, ga, ta', () {
test('isChatUse is true for wa, ga, ta and granular IGC/IT types', () {
expect(ConstructUseTypeEnum.wa.isChatUse, true);
expect(ConstructUseTypeEnum.ga.isChatUse, true);
expect(ConstructUseTypeEnum.ta.isChatUse, true);
expect(ConstructUseTypeEnum.corIGC.isChatUse, true);
expect(ConstructUseTypeEnum.incIGC.isChatUse, true);
expect(ConstructUseTypeEnum.ignIGC.isChatUse, true);
expect(ConstructUseTypeEnum.corIt.isChatUse, true);
expect(ConstructUseTypeEnum.incIt.isChatUse, true);
expect(ConstructUseTypeEnum.ignIt.isChatUse, true);
expect(ConstructUseTypeEnum.corPA.isChatUse, false);
expect(ConstructUseTypeEnum.incLM.isChatUse, false);
expect(ConstructUseTypeEnum.click.isChatUse, false);
});
test('isAssistedChatUse is true for ga, ta only', () {
test('isAssistedChatUse is true for ga, ta and granular IGC/IT types', () {
expect(ConstructUseTypeEnum.ga.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.ta.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.corIGC.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.incIGC.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.ignIGC.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.corIt.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.incIt.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.ignIt.isAssistedChatUse, true);
expect(ConstructUseTypeEnum.wa.isAssistedChatUse, false);
});
@ -132,12 +146,22 @@ void main() {
expect(uses.practiceTier, PracticeTier.active);
});
test('ga use (IGC correction) → active', () {
test('ignIGC use → active', () {
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ignIGC)]);
expect(uses.practiceTier, PracticeTier.active);
});
test('corIt use (IT translation) → active', () {
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.corIt)]);
expect(uses.practiceTier, PracticeTier.active);
});
test('ga use (legacy IGC correction) → active', () {
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ga)]);
expect(uses.practiceTier, PracticeTier.active);
});
test('ta use (IT translation) → active', () {
test('ta use (legacy IT translation) → active', () {
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ta)]);
expect(uses.practiceTier, PracticeTier.active);
});