cyberhybridhub/server/lib/question_dispatcher.dart
2026-06-06 19:23:18 +00:00

362 lines
12 KiB
Dart

import 'dart:math';
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.debugLoggingEnabled = false,
this.maxQueuedQuestions = 3,
Random? random,
}) : _questionsDb = questionsDb,
_questionService = questionService,
_questionPipeline = questionPipeline,
_preferencesDb = preferencesDb,
_random = random ?? Random();
final QuestionsDb _questionsDb;
final QuestionService _questionService;
final QuestionPipeline _questionPipeline;
final QuestionSourcePreferencesDb _preferencesDb;
final Random _random;
final bool debugLoggingEnabled;
final int maxQueuedQuestions;
void _log(String message) {
if (debugLoggingEnabled) {
// ignore: avoid_print
print('[QuestionDispatcher] $message');
}
}
/// Maximum maintenance passes before giving up on an empty queue.
static const int _maxQueueAttempts = 6;
/// Ensures at least one enabled-source question exists; returns mix-ordered head.
Future<Map<String, dynamic>?> dispatchNextQuestion(String firebaseUid) async {
_log('dispatchNextQuestion($firebaseUid)');
await _questionsDb.ensureUserExists(firebaseUid);
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
_log('Initial unanswered for mix: ${rows.length} rows');
for (var attempt = 0;
rows.isEmpty && attempt < _maxQueueAttempts;
attempt++) {
_log('Queue empty, running maintenance (attempt $attempt)');
await _runQueueMaintenance(firebaseUid);
rows = await _listEnabledUnansweredOrdered(firebaseUid);
}
if (rows.isEmpty) {
_log('Still empty after maintenance, returning null');
return null;
}
_log('Returning first row out of ${rows.length}');
return _toClientPayload(rows.first, rows.length);
}
/// Unanswered questions for enabled sources only, ordered by configured mix.
///
/// Runs [_runQueueMaintenance] in a loop until at least one question is ready
/// or [_maxQueueAttempts] are exhausted. This ensures spelling enqueue,
/// catalog seeding, and pipeline-state setup all complete before returning.
Future<List<Map<String, dynamic>>> listPanelQuestions(
String firebaseUid,
) async {
await _questionsDb.ensureUserExists(firebaseUid);
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
for (var attempt = 0;
rows.isEmpty && attempt < _maxQueueAttempts;
attempt++) {
await _runQueueMaintenance(firebaseUid);
rows = await _listEnabledUnansweredOrdered(firebaseUid);
}
return rows
.map((Map<String, dynamic> row) => _toClientPayload(row, rows.length))
.toList(growable: false);
}
/// Counts the number of unanswered questions that belong to enabled sources.
Future<int> countEnabledUnansweredQuestions(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)
.where((String id) => (mix[id] ?? 0) > 0)
.toSet();
return _questionsDb.countUnansweredForAllowedSources(firebaseUid, allowed);
}
/// Next unanswered enabled-source question after an answer.
Future<Map<String, dynamic>?> nextQuestionAfterAnswer({
required String firebaseUid,
required Map<String, dynamic> answeredQuestion,
}) async {
_log('nextQuestionAfterAnswer($firebaseUid)');
final String answeredSourceId = QuestionsDb.resolveSourceId(answeredQuestion);
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
if (rows.isEmpty) {
_log('Queue empty, falling back to dispatchNextQuestion');
return dispatchNextQuestion(firebaseUid);
}
// Pipeline continuity: If we just answered a spelling question and another
// spelling question is available (e.g. follow-up or next word quiz), return it
// immediately. Pipelines should not be interrupted by mix changes.
if (answeredSourceId == 'spelling') {
for (final Map<String, dynamic> row in rows) {
if (QuestionsDb.resolveSourceId(row) == 'spelling') {
return _toClientPayload(row, rows.length);
}
}
}
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)
.where((String id) => (mix[id] ?? 0) > 0)
.toSet();
_log('nextQuestionAfterAnswer: mix=$mix, allowed=$allowed, delivered=$delivered');
final Map<String, dynamic>? row = await _questionsDb.findHeadUnansweredForMix(
firebaseUid,
effectiveMixPercent: mix,
deliveredCounts: delivered,
allowedSourceIds: allowed,
random: _random,
);
if (row == null) {
return dispatchNextQuestion(firebaseUid);
}
return _toClientPayload(row, rows.length);
}
/// Next unanswered enabled-source question after deferring a question.
Future<Map<String, dynamic>?> nextQuestionAfterDefer({
required String firebaseUid,
required String deferredQuestionId,
}) async {
final List<Map<String, dynamic>> rows =
await _listEnabledUnansweredOrdered(firebaseUid);
if (rows.isEmpty) {
return dispatchNextQuestion(firebaseUid);
}
for (final Map<String, dynamic> row in rows) {
if (row['id'] != deferredQuestionId) {
return _toClientPayload(row, rows.length);
}
}
return _toClientPayload(rows.first, 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;
}
}
}
/// Runs both mix-based enqueue and spelling-specific maintenance in one pass.
///
/// Both helpers are idempotent (spelling checks for an existing unanswered
/// word-quiz before creating a new one), so calling this in a loop is safe.
Future<void> _runQueueMaintenance(String firebaseUid) async {
if (!await _enqueueOneFromMix(firebaseUid)) {
await _ensureSpellingQuestionWhenNeeded(firebaseUid);
}
}
Future<bool> _enqueueOneFromMix(String firebaseUid) async {
_log('_enqueueOneFromMix($firebaseUid)');
final List<ResolvedQuestionSourcePreference> prefs =
await _preferencesDb.getResolved(firebaseUid);
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
if (mix.isEmpty) {
_log('_enqueueOneFromMix: mix is empty');
return false;
}
final Map<String, int> delivered =
await _questionsDb.countDeliveredBySourceId(firebaseUid);
final String primary = QuestionSourceMix.pickNextSourceId(
effectiveMixPercent: mix,
deliveredCounts: delivered,
random: _random,
);
_log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)');
final List<String> order = _sourceTryOrderForEnqueue(
prefs: prefs,
primarySourceId: primary,
).where((String id) => (mix[id] ?? 0) > 0).toList(growable: false);
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);
// Only include sources that are both enabled and have a non-zero mix share.
// A source with enabled=true but ratio=0 should not serve questions.
final Set<String> allowed = _enabledSourceIds(prefs)
.where((String id) => (mix[id] ?? 0) > 0)
.toSet();
if (allowed.isEmpty) {
_log('_listEnabledUnansweredOrdered: allowed is empty');
return <Map<String, dynamic>>[];
}
_log('_listEnabledUnansweredOrdered: fetching for allowed=$allowed');
return _questionsDb.listUnansweredOrderedForMix(
firebaseUid,
effectiveMixPercent: mix,
allowedSourceIds: allowed,
random: _random,
);
}
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,
);
}
}