Some checks failed
Admin portal tests / test (push) Has been cancelled
- Replaced single Postgres Connection with Pool across the server to prevent runTx locking - Added _filterForEnabledSources to client's _fetchUnansweredForPanel to strictly enforce local configuration Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
11 KiB
Dart
321 lines
11 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;
|
|
|
|
/// 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 {
|
|
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);
|
|
}
|
|
|
|
if (rows.isEmpty) {
|
|
return null;
|
|
}
|
|
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 {
|
|
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)
|
|
.where((String id) => (mix[id] ?? 0) > 0)
|
|
.toSet();
|
|
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);
|
|
}
|
|
|
|
/// 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 {
|
|
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,
|
|
).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) {
|
|
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,
|
|
);
|
|
}
|
|
}
|