289 lines
9.3 KiB
Dart
289 lines
9.3 KiB
Dart
import 'pipeline/question_pipeline.dart';
|
|
import 'question_service.dart';
|
|
import 'question_source_mix.dart';
|
|
import 'question_source_preferences.dart';
|
|
import 'question_source_preferences_db.dart';
|
|
import 'questions_db.dart';
|
|
|
|
/// Ratio-aware entry point for topping up a user's unanswered question queue.
|
|
class QuestionDispatcher {
|
|
QuestionDispatcher({
|
|
required QuestionsDb questionsDb,
|
|
required QuestionService questionService,
|
|
required QuestionPipeline questionPipeline,
|
|
required QuestionSourcePreferencesDb preferencesDb,
|
|
this.maxQueuedQuestions = 3,
|
|
}) : _questionsDb = questionsDb,
|
|
_questionService = questionService,
|
|
_questionPipeline = questionPipeline,
|
|
_preferencesDb = preferencesDb;
|
|
|
|
final QuestionsDb _questionsDb;
|
|
final QuestionService _questionService;
|
|
final QuestionPipeline _questionPipeline;
|
|
final QuestionSourcePreferencesDb _preferencesDb;
|
|
final int maxQueuedQuestions;
|
|
|
|
/// Ensures at least one enabled-source question exists; returns mix-ordered head.
|
|
Future<Map<String, dynamic>?> dispatchNextQuestion(String firebaseUid) async {
|
|
await _questionsDb.ensureUserExists(firebaseUid);
|
|
|
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
if (rows.isEmpty) {
|
|
if (!await _enqueueOneFromMix(firebaseUid)) {
|
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
|
}
|
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
}
|
|
if (rows.isEmpty) {
|
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
}
|
|
|
|
if (rows.isEmpty) {
|
|
return null;
|
|
}
|
|
return _toClientPayload(rows.first, rows.length);
|
|
}
|
|
|
|
/// Ensures an enabled-source question exists before the question panel opens.
|
|
Future<void> preparePanelQueue(String firebaseUid) async {
|
|
await _questionsDb.ensureUserExists(firebaseUid);
|
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
if (rows.isEmpty) {
|
|
await _enqueueOneFromMix(firebaseUid);
|
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
}
|
|
if (rows.isEmpty) {
|
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
|
}
|
|
}
|
|
|
|
/// Unanswered questions for enabled sources only, ordered by configured mix.
|
|
Future<List<Map<String, dynamic>>> listPanelQuestions(
|
|
String firebaseUid,
|
|
) async {
|
|
await preparePanelQueue(firebaseUid);
|
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
if (rows.isEmpty) {
|
|
await _enqueueOneFromMix(firebaseUid);
|
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
}
|
|
if (rows.isEmpty) {
|
|
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
|
|
rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
}
|
|
return rows
|
|
.map(
|
|
(Map<String, dynamic> row) =>
|
|
_toClientPayload(row, rows.length),
|
|
)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// Next unanswered enabled-source question after an answer.
|
|
Future<Map<String, dynamic>?> nextQuestionAfterAnswer({
|
|
required String firebaseUid,
|
|
required Map<String, dynamic> answeredQuestion,
|
|
}) async {
|
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
|
if (rows.isEmpty) {
|
|
return dispatchNextQuestion(firebaseUid);
|
|
}
|
|
|
|
final List<ResolvedQuestionSourcePreference> prefs =
|
|
await _preferencesDb.getResolved(firebaseUid);
|
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
|
final Map<String, int> delivered =
|
|
await _questionsDb.countDeliveredBySourceId(firebaseUid);
|
|
final Set<String> allowed = _enabledSourceIds(prefs);
|
|
final Map<String, dynamic>? row = await _questionsDb.findHeadUnansweredForMix(
|
|
firebaseUid,
|
|
effectiveMixPercent: mix,
|
|
deliveredCounts: delivered,
|
|
afterAnsweringSourceId:
|
|
QuestionsDb.resolveSourceId(answeredQuestion),
|
|
allowedSourceIds: allowed,
|
|
);
|
|
if (row == null) {
|
|
return dispatchNextQuestion(firebaseUid);
|
|
}
|
|
return _toClientPayload(row, rows.length);
|
|
}
|
|
|
|
/// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks.
|
|
Future<void> topUpQueue(String firebaseUid) async {
|
|
await _questionsDb.ensureUserExists(firebaseUid);
|
|
|
|
while (true) {
|
|
final int enabledQueued =
|
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
|
if (enabledQueued >= maxQueuedQuestions) {
|
|
return;
|
|
}
|
|
if (!await _enqueueOneFromMix(firebaseUid)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<bool> _enqueueOneFromMix(String firebaseUid) async {
|
|
final List<ResolvedQuestionSourcePreference> prefs =
|
|
await _preferencesDb.getResolved(firebaseUid);
|
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
|
if (mix.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
final Map<String, int> delivered =
|
|
await _questionsDb.countDeliveredBySourceId(firebaseUid);
|
|
final String primary = QuestionSourceMix.pickNextSourceId(
|
|
effectiveMixPercent: mix,
|
|
deliveredCounts: delivered,
|
|
);
|
|
final List<String> order = _sourceTryOrderForEnqueue(
|
|
prefs: prefs,
|
|
primarySourceId: primary,
|
|
);
|
|
|
|
final int? marketBatchCap = _marketBatchCapForMix(prefs);
|
|
|
|
for (final String sourceId in order) {
|
|
final bool added = await _produceFromSource(
|
|
firebaseUid,
|
|
sourceId,
|
|
marketBatchCap: marketBatchCap,
|
|
);
|
|
if (added) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _listEnabledUnansweredOrdered(
|
|
String firebaseUid,
|
|
) async {
|
|
final List<ResolvedQuestionSourcePreference> prefs =
|
|
await _preferencesDb.getResolved(firebaseUid);
|
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
|
final Set<String> allowed = _enabledSourceIds(prefs);
|
|
if (allowed.isEmpty) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
return _questionsDb.listUnansweredOrderedForMix(
|
|
firebaseUid,
|
|
effectiveMixPercent: mix,
|
|
allowedSourceIds: allowed,
|
|
);
|
|
}
|
|
|
|
Set<String> _enabledSourceIds(
|
|
List<ResolvedQuestionSourcePreference> prefs,
|
|
) {
|
|
return prefs
|
|
.where(
|
|
(ResolvedQuestionSourcePreference s) =>
|
|
s.enabled && !s.comingSoon,
|
|
)
|
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
|
.toSet();
|
|
}
|
|
|
|
List<String> _sourceTryOrderForEnqueue({
|
|
required List<ResolvedQuestionSourcePreference> prefs,
|
|
required String primarySourceId,
|
|
}) {
|
|
final List<ResolvedQuestionSourcePreference> enabled = prefs
|
|
.where(
|
|
(ResolvedQuestionSourcePreference s) =>
|
|
s.enabled && !s.comingSoon,
|
|
)
|
|
.toList(growable: false);
|
|
final List<String> rest = enabled
|
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
|
.where((String id) => id != primarySourceId)
|
|
.toList()
|
|
..sort(
|
|
(
|
|
String a,
|
|
String b,
|
|
) {
|
|
final int ratioA = enabled
|
|
.firstWhere((ResolvedQuestionSourcePreference s) => s.id == a)
|
|
.ratio;
|
|
final int ratioB = enabled
|
|
.firstWhere((ResolvedQuestionSourcePreference s) => s.id == b)
|
|
.ratio;
|
|
return ratioB.compareTo(ratioA);
|
|
},
|
|
);
|
|
return <String>[primarySourceId, ...rest];
|
|
}
|
|
|
|
/// Last-resort spelling enqueue when mix dispatch did not produce a row.
|
|
Future<void> _ensureSpellingQuestionWhenNeeded(String firebaseUid) async {
|
|
final List<ResolvedQuestionSourcePreference> prefs =
|
|
await _preferencesDb.getResolved(firebaseUid);
|
|
if (!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
|
|
return;
|
|
}
|
|
await _questionPipeline.maintainSpellingForUser(firebaseUid);
|
|
}
|
|
|
|
Future<bool> _produceFromSource(
|
|
String firebaseUid,
|
|
String sourceId, {
|
|
int? marketBatchCap,
|
|
}) async {
|
|
if (!await _isSourceEnabled(firebaseUid, sourceId)) {
|
|
return false;
|
|
}
|
|
final int before =
|
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
|
switch (sourceId) {
|
|
case 'market_history':
|
|
await _questionService.tryEnqueueMarketQuestions(
|
|
firebaseUid,
|
|
maxCreates: marketBatchCap,
|
|
);
|
|
case 'spelling':
|
|
await _questionPipeline.maintainSpellingForUser(firebaseUid);
|
|
default:
|
|
return false;
|
|
}
|
|
final int after =
|
|
(await _listEnabledUnansweredOrdered(firebaseUid)).length;
|
|
return after > before;
|
|
}
|
|
|
|
Future<bool> _isSourceEnabled(String firebaseUid, String sourceId) async {
|
|
final List<ResolvedQuestionSourcePreference> prefs =
|
|
await _preferencesDb.getResolved(firebaseUid);
|
|
return QuestionSourcePreferences.isEnabled(prefs, sourceId);
|
|
}
|
|
|
|
int? _marketBatchCapForMix(List<ResolvedQuestionSourcePreference> prefs) {
|
|
if (!QuestionSourcePreferences.isEnabled(prefs, 'market_history') ||
|
|
!QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
|
|
return null;
|
|
}
|
|
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
|
|
final int spellingShare = mix['spelling'] ?? 0;
|
|
if (spellingShare <= 0) {
|
|
return null;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
Map<String, dynamic> _toClientPayload(
|
|
Map<String, dynamic> row,
|
|
int unansweredCount,
|
|
) {
|
|
return _questionsDb.toClientPayload(
|
|
row,
|
|
unansweredCount: unansweredCount,
|
|
);
|
|
}
|
|
}
|