5053 can get points from lemma with max score (#5078)

* make uses a private field for ConstructUses

* expose capped list of uses in ConstructUses

* filter capped construct uses in getUses
This commit is contained in:
ggurdin 2026-01-06 11:39:49 -05:00 committed by GitHub
parent 0ba50f9d73
commit 2c176c052d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 164 additions and 146 deletions

View file

@ -241,7 +241,23 @@ class AnalyticsDataService {
);
final blocked = blockedConstructs;
return uses.where((use) => !blocked.contains(use.identifier)).toList();
final List<OneConstructUse> filtered = [];
final Map<ConstructIdentifier, DateTime?> cappedLastUseCache = {};
for (final use in uses) {
if (blocked.contains(use.identifier)) continue;
if (!cappedLastUseCache.containsKey(use.identifier)) {
final constructs = await getConstructUse(use.identifier);
cappedLastUseCache[use.identifier] = constructs.cappedLastUse;
}
final cappedLastUse = cappedLastUseCache[use.identifier];
if (cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse)) {
continue;
}
filtered.add(use);
}
return filtered;
}
Future<List<OneConstructUse>> getLocalUses() async {

View file

@ -199,82 +199,55 @@ class AnalyticsDatabase with DatabaseFileStorage {
DateTime? since,
}) async {
final stopwatch = Stopwatch()..start();
final List<OneConstructUse> uses = [];
final results = <OneConstructUse>[];
// first, get all of the local (most recent) keys
final localKeys = await _localConstructsBox.getAllKeys();
final localValues = await _localConstructsBox.getAll(localKeys);
final local = Map.fromIterables(
localKeys,
localValues,
).entries.toList();
local.sort(
(a, b) => int.parse(b.key).compareTo(int.parse(a.key)),
);
for (final entry in local) {
// filter by date
if (since != null &&
int.parse(entry.key) < since.millisecondsSinceEpoch) {
continue;
bool addUseIfValid(OneConstructUse use) {
if (since != null && use.timeStamp.isBefore(since)) {
return false; // stop iteration entirely
}
if (roomId != null && use.metadata.roomId != roomId) {
return true; // skip but continue
}
final rawUses = entry.value;
if (rawUses == null) continue;
for (final raw in rawUses) {
// filter by count
if (count != null && uses.length >= count) break;
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
// filter by roomID
if (roomId != null && use.metadata.roomId != roomId) {
continue;
}
uses.add(use);
}
if (count != null && uses.length >= count) break;
results.add(use);
return count == null || results.length < count;
}
if (count != null && uses.length >= count) return uses;
// then get server uses
final serverKeys = await _serverConstructsBox.getAllKeys();
serverKeys.sort(
(a, b) =>
int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])),
);
// ---- Local uses ----
final localUses = await getLocalUses()
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
for (final use in localUses) {
if (!addUseIfValid(use)) break;
}
if (count != null && results.length >= count) {
stopwatch.stop();
Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms");
return results;
}
// ---- Server uses ----
final serverKeys = await _serverConstructsBox.getAllKeys()
..sort(
(a, b) =>
int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])),
);
for (final key in serverKeys) {
// filter by count
if (count != null && uses.length >= count) break;
final rawUses = await _serverConstructsBox.get(key);
if (rawUses == null) continue;
for (final raw in rawUses) {
if (count != null && uses.length >= count) break;
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
final serverUses = await getServerUses(key)
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
// filter by roomID
if (roomId != null && use.metadata.roomId != roomId) {
continue;
}
// filter by date
if (since != null && use.timeStamp.isBefore(since)) {
continue;
}
uses.add(use);
for (final use in serverUses) {
if (!addUseIfValid(use)) break;
}
if (count != null && results.length >= count) break;
}
stopwatch.stop();
Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms");
return uses.take(count ?? uses.length).toList();
return results;
}
Future<List<OneConstructUse>> getLocalUses() async {
@ -293,6 +266,21 @@ class AnalyticsDatabase with DatabaseFileStorage {
return uses;
}
Future<List<OneConstructUse>> getServerUses(String key) async {
final List<OneConstructUse> uses = [];
final serverValues = await _serverConstructsBox.get(key);
if (serverValues == null) return [];
for (final entry in serverValues) {
uses.add(
OneConstructUse.fromJson(
Map<String, dynamic>.from(entry),
),
);
}
return uses;
}
Future<int> getLocalConstructCount() async {
final keys = await _localConstructsBox.getAllKeys();
return keys.length;
@ -408,8 +396,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
}
for (final u in usesForKey) {
model.uses.add(u);
model.setLastUsed(u.timeStamp);
model.addUse(u);
}
updates[key] = model;

View file

@ -13,7 +13,10 @@ class ConstructMergeTable {
List<ConstructUses> constructs,
Set<ConstructIdentifier> exclude,
) {
addConstructsByUses(constructs.expand((c) => c.uses).toList(), exclude);
addConstructsByUses(
constructs.expand((c) => c.cappedUses).toList(),
exclude,
);
}
void addConstructsByUses(

View file

@ -26,7 +26,7 @@ class LemmaUsageDots extends StatelessWidget {
/// Find lemma uses for the given exercise type, to create dot list
List<bool> sortedUses(LearningSkillsEnum category) {
final List<bool> useList = [];
for (final OneConstructUse use in construct.uses) {
for (final OneConstructUse use in construct.cappedUses) {
if (use.xp == 0) {
continue;
}

View file

@ -25,7 +25,7 @@ class LemmaUseExampleMessages extends StatelessWidget {
Future<List<ExampleMessage>> _getExampleMessages() async {
final List<ExampleMessage> examples = [];
for (final OneConstructUse use in construct.uses) {
for (final OneConstructUse use in construct.cappedUses) {
if (use.useType.skillsEnumType != LearningSkillsEnum.writing ||
use.metadata.eventId == null ||
use.form == null ||

View file

@ -65,13 +65,7 @@ class VocabDetailsView extends StatelessWidget {
? level.color(context)
: level.darkColor(context));
final forms = construct?.uses
.map((e) => e.form)
.whereType<String>()
.toSet()
.toList() ??
[];
final forms = construct?.forms ?? [];
final tokenText = PangeaTokenText.fromString(constructId.lemma);
final token = PangeaToken(
text: tokenText,

View file

@ -165,7 +165,8 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
final xp = uses.map((e) => e.points).reduce((a, total) => a + total);
final exampleMessages = await _getExampleMessages(uses);
final allUses = uses.map((u) => u.uses).expand((element) => element);
final allUses =
uses.map((u) => u.cappedUses).expand((element) => element);
int independantUseOccurrences = 0;
int assistedUseOccurrences = 0;
@ -218,7 +219,7 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
final xp = uses.points;
final exampleMessages = await _getExampleMessages([uses]);
final allUses = uses.uses;
final allUses = uses.cappedUses;
int independantUseOccurrences = 0;
int assistedUseOccurrences = 0;
@ -261,7 +262,8 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
Future<List<String>> _getExampleMessages(
List<ConstructUses> constructUses,
) async {
final allUses = constructUses.map((e) => e.uses).expand((e) => e).toList();
final allUses =
constructUses.map((e) => e.cappedUses).expand((e) => e).toList();
final List<PangeaMessageEvent> examples = [];
for (final OneConstructUse use in allUses) {
if (use.metadata.roomId == null) continue;

View file

@ -252,7 +252,7 @@ class SpaceAnalyticsSummaryModel {
final systemUsesCorrect = [];
final systemUsesIncorrect = [];
for (final use in entry.uses) {
for (final use in entry.cappedUses) {
if (originalUseTypes.contains(use.useType)) {
use.xp > 0
? originalUsesCorrect.add(use)

View file

@ -1,30 +1,35 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.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/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
/// One lemma and a list of construct uses for that lemma
class ConstructUses {
final List<OneConstructUse> uses;
final List<OneConstructUse> _uses;
final ConstructTypeEnum constructType;
final String lemma;
String? _category;
DateTime? _lastUsed;
ConstructUses({
required this.uses,
required List<OneConstructUse> uses,
required this.constructType,
required this.lemma,
required category,
}) : _category = category;
}) : _category = category,
_uses = List<OneConstructUse>.from(uses) {
_sortUses();
}
// Total points for all uses of this lemma
int get points {
return min(
uses.fold<int>(
_uses.fold<int>(
0,
(total, use) => total + use.xp,
),
@ -32,28 +37,16 @@ class ConstructUses {
);
}
DateTime? get lastUsed {
if (_lastUsed != null) return _lastUsed;
final lastUse = uses.fold<DateTime?>(null, (DateTime? last, use) {
if (last == null) return use.timeStamp;
return use.timeStamp.isAfter(last) ? use.timeStamp : last;
});
return _lastUsed = lastUse;
}
void setLastUsed(DateTime time) {
if (_lastUsed == null || time.isAfter(_lastUsed!)) {
_lastUsed = time;
}
}
DateTime? get lastUsed => _uses.lastOrNull?.timeStamp;
DateTime? get cappedLastUse => cappedUses.lastOrNull?.timeStamp;
String get category {
if (_category == null || _category!.isEmpty) return "other";
return _category!.toLowerCase();
}
bool get hasCorrectUse => uses.any((use) => use.xp > 0);
bool get hasIncorrectUse => uses.any((use) => use.xp < 0);
bool get hasCorrectUse => _uses.any((use) => use.xp > 0);
bool get hasIncorrectUse => _uses.any((use) => use.xp < 0);
ConstructIdentifier get id => ConstructIdentifier(
lemma: lemma,
@ -61,38 +54,6 @@ class ConstructUses {
category: category,
);
Map<String, dynamic> toJson() {
final json = {
'construct_id': id.toJson(),
'xp': points,
'last_used': lastUsed?.toIso8601String(),
'uses': uses.map((e) => e.toJson()).toList(),
};
return json;
}
factory ConstructUses.fromJson(Map<String, dynamic> json) {
final constructId = ConstructIdentifier.fromJson(
Map<String, dynamic>.from(json['construct_id']),
);
List<dynamic> usesJson = [];
if (json['uses'] is List) {
usesJson = List<dynamic>.from(json['uses']);
}
final uses = usesJson
.map((e) => OneConstructUse.fromJson(Map<String, dynamic>.from(e)))
.toList();
return ConstructUses(
uses: uses,
constructType: constructId.type,
lemma: constructId.lemma,
category: constructId.category,
);
}
/// Get the lemma category, based on points
ConstructLevelEnum get lemmaCategory {
if (points < AnalyticsConstants.xpForGreens) {
@ -122,6 +83,66 @@ class ConstructUses {
_ => ConstructLevelEnum.flowers,
};
List<String> get forms =>
_uses.map((e) => e.form).whereType<String>().toSet().toList();
List<OneConstructUse> get cappedUses {
final result = <OneConstructUse>[];
var totalXp = 0;
for (final use in _uses) {
if (totalXp >= AnalyticsConstants.xpForFlower) break;
totalXp += use.xp;
result.add(use);
}
return result;
}
DateTime? lastUseByTypes(List<ConstructUseTypeEnum> types) =>
_uses.lastWhereOrNull((u) => types.contains(u.useType))?.timeStamp;
Map<String, dynamic> toJson() {
final json = {
'construct_id': id.toJson(),
'xp': points,
'last_used': lastUsed?.toIso8601String(),
'uses': _uses.map((e) => e.toJson()).toList(),
};
return json;
}
factory ConstructUses.fromJson(Map<String, dynamic> json) {
final constructId = ConstructIdentifier.fromJson(
Map<String, dynamic>.from(json['construct_id']),
);
List<dynamic> usesJson = [];
if (json['uses'] is List) {
usesJson = List<dynamic>.from(json['uses']);
}
final uses = usesJson
.map((e) => OneConstructUse.fromJson(Map<String, dynamic>.from(e)))
.toList();
return ConstructUses(
uses: uses,
constructType: constructId.type,
lemma: constructId.lemma,
category: constructId.category,
);
}
void _sortUses() {
_uses.sort((a, b) => a.timeStamp.compareTo(b.timeStamp));
}
void addUse(OneConstructUse use) {
_uses.add(use);
_sortUses();
}
void merge(ConstructUses other) {
if (other.lemma.toLowerCase() != lemma.toLowerCase() ||
other.constructType != constructType) {
@ -130,10 +151,8 @@ class ConstructUses {
);
}
uses.addAll(other.uses);
if (other.lastUsed != null) {
setLastUsed(other.lastUsed!);
}
_uses.addAll(other._uses);
_sortUses();
if (category == 'other' && other.category != 'other') {
_category = other.category;
@ -147,7 +166,7 @@ class ConstructUses {
String? category,
}) {
return ConstructUses(
uses: uses ?? this.uses,
uses: uses ?? _uses,
constructType: constructType ?? this.constructType,
lemma: lemma ?? this.lemma,
category: category ?? _category,

View file

@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -242,13 +241,11 @@ class PracticeSelectionRepo {
for (final token in tokens) {
final construct = constructs[idMap[token]];
final lastUsed = construct?.uses.firstWhereOrNull(
(u) => activityType.associatedUseTypes.contains(u.useType),
);
final lastUsed =
construct?.lastUseByTypes(activityType.associatedUseTypes);
final daysSinceLastUsed = lastUsed == null
? 20
: DateTime.now().difference(lastUsed.timeStamp).inDays;
final daysSinceLastUsed =
lastUsed == null ? 20 : DateTime.now().difference(lastUsed).inDays;
scores[token] =
daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 9);