481 lines
16 KiB
Dart
481 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import '../question_service.dart';
|
|
import '../questions_db.dart';
|
|
import '../trading/trading_pipeline.dart';
|
|
import 'branch_decision.dart';
|
|
import 'external_data_fetcher.dart';
|
|
|
|
/// Test-mode question shape for local pipeline testing.
|
|
abstract final class PipelineTestQuestions {
|
|
static const String text =
|
|
'Starter question: enter a whole number between -10 and 10.';
|
|
|
|
static int randomCorrectAnswer() => Random().nextInt(21) - 10;
|
|
}
|
|
|
|
/// Pipeline keys stored in [user_pipeline_state] and question rows.
|
|
abstract final class PipelineKeys {
|
|
static const String root = 'root';
|
|
static const String geography = 'geography';
|
|
static const String weather = 'weather';
|
|
static const String trading = 'trading';
|
|
}
|
|
|
|
/// Steps within each pipeline branch.
|
|
abstract final class PipelineSteps {
|
|
static const String chooseTrack = 'choose_track';
|
|
static const String populationQuiz = 'population_quiz';
|
|
static const String capitalFollowUp = 'capital_follow_up';
|
|
static const String capitalRecovery = 'capital_recovery';
|
|
static const String temperatureQuiz = 'temperature_quiz';
|
|
static const String temperatureFollowUp = 'temperature_follow_up';
|
|
static const String idle = 'idle';
|
|
}
|
|
|
|
/// Per-rule phases stored in [user_trading_state.context.rules.{ruleId}.phase].
|
|
abstract final class TradingPhases {
|
|
/// No active question; rule engine evaluates each tick.
|
|
static const String idle = 'idle';
|
|
|
|
/// Question delivered, awaiting +10/-10 from the user.
|
|
static const String awaitConfirm = 'await_confirm';
|
|
|
|
/// User said +10; order staged in pending_orders for the actuator.
|
|
static const String submitOrder = 'submit_order';
|
|
|
|
/// Guess-the-move question awaiting +10/-10 (scoring only, no order).
|
|
static const String awaitAnswer = 'await_answer';
|
|
|
|
/// Outcome recorded; rule returns to [idle] after cooldown rolls off.
|
|
static const String done = 'done';
|
|
}
|
|
|
|
/// Orchestrates API-driven question creation and branches on user answers.
|
|
class QuestionPipeline {
|
|
QuestionPipeline({
|
|
required QuestionsDb questionsDb,
|
|
required QuestionService questionService,
|
|
ExternalDataFetcher? fetcher,
|
|
TradingPipeline? tradingPipeline,
|
|
this.maxQueuedQuestions = 3,
|
|
this.testMode = false,
|
|
}) : _questionsDb = questionsDb,
|
|
_questionService = questionService,
|
|
_fetcher = fetcher ?? ExternalDataFetcher(),
|
|
_tradingPipeline = tradingPipeline;
|
|
|
|
final QuestionsDb _questionsDb;
|
|
final QuestionService _questionService;
|
|
final ExternalDataFetcher _fetcher;
|
|
final TradingPipeline? _tradingPipeline;
|
|
final int maxQueuedQuestions;
|
|
final bool testMode;
|
|
|
|
/// Periodic tick: start pipelines for idle users and top up shallow queues.
|
|
Future<void> runMaintenanceCycle() async {
|
|
final List<String> userIds = await _questionsDb.listAllUserFirebaseUids();
|
|
for (final String uid in userIds) {
|
|
try {
|
|
await _maintainUser(uid);
|
|
} catch (e, st) {
|
|
stderr.writeln('Pipeline maintenance failed for $uid: $e\n$st');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _maintainUser(String firebaseUid) async {
|
|
final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
|
if (queued >= maxQueuedQuestions) {
|
|
return;
|
|
}
|
|
|
|
final Map<String, dynamic>? state =
|
|
await _questionsDb.getPipelineState(firebaseUid);
|
|
if (state == null) {
|
|
if (queued == 0) {
|
|
await _startRootPipeline(firebaseUid);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final String step = state['step'] as String;
|
|
if (step == PipelineSteps.idle && queued == 0) {
|
|
await _advanceFromIdle(firebaseUid, state);
|
|
}
|
|
}
|
|
|
|
Future<void> _deliverPipelineQuestion({
|
|
required String firebaseUid,
|
|
required String questionText,
|
|
required num correctAnswer,
|
|
required String sourceTag,
|
|
required String pipelineKey,
|
|
required String pipelineStep,
|
|
}) async {
|
|
await _questionService.createAndDeliverQuestion(
|
|
assignedUserId: firebaseUid,
|
|
questionText:
|
|
testMode ? PipelineTestQuestions.text : questionText,
|
|
correctAnswer:
|
|
testMode ? PipelineTestQuestions.randomCorrectAnswer() : correctAnswer,
|
|
sourceTag: sourceTag,
|
|
pipelineKey: pipelineKey,
|
|
pipelineStep: pipelineStep,
|
|
);
|
|
}
|
|
|
|
BranchOutcome _evaluateAnswer({
|
|
required num userResponse,
|
|
required num correctAnswer,
|
|
required BranchOutcome Function() production,
|
|
}) {
|
|
if (testMode) {
|
|
return userResponse.round() == correctAnswer.round()
|
|
? BranchOutcome.match
|
|
: BranchOutcome.miss;
|
|
}
|
|
return production();
|
|
}
|
|
|
|
Future<bool> _canEnqueue(String firebaseUid) async {
|
|
final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid);
|
|
return queued < maxQueuedQuestions;
|
|
}
|
|
|
|
/// Called after a question is answered; may enqueue the next branched question.
|
|
Future<void> onAnswerSubmitted({
|
|
required String firebaseUid,
|
|
required Map<String, dynamic> answeredQuestion,
|
|
required num userResponse,
|
|
}) async {
|
|
final String? pipelineKey = answeredQuestion['pipelineKey'] as String?;
|
|
final String? pipelineStep = answeredQuestion['pipelineStep'] as String?;
|
|
if (pipelineKey == null || pipelineStep == null) {
|
|
return;
|
|
}
|
|
|
|
// Trading answers (including scoring) must run even when the queue is full.
|
|
if (pipelineKey == PipelineKeys.trading && _tradingPipeline != null) {
|
|
await _tradingPipeline.handleAnswer(
|
|
firebaseUid: firebaseUid,
|
|
answeredQuestion: answeredQuestion,
|
|
userResponse: userResponse,
|
|
);
|
|
}
|
|
|
|
if (!await _canEnqueue(firebaseUid)) {
|
|
return;
|
|
}
|
|
|
|
final num correctAnswer = answeredQuestion['correctAnswer'] as num;
|
|
final Map<String, dynamic> context =
|
|
await _loadContext(firebaseUid, pipelineKey);
|
|
|
|
switch (pipelineKey) {
|
|
case PipelineKeys.root:
|
|
await _handleRootAnswer(
|
|
firebaseUid: firebaseUid,
|
|
step: pipelineStep,
|
|
userResponse: userResponse,
|
|
);
|
|
case PipelineKeys.geography:
|
|
await _handleGeographyAnswer(
|
|
firebaseUid: firebaseUid,
|
|
step: pipelineStep,
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
context: context,
|
|
);
|
|
case PipelineKeys.weather:
|
|
await _handleWeatherAnswer(
|
|
firebaseUid: firebaseUid,
|
|
step: pipelineStep,
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
context: context,
|
|
);
|
|
case PipelineKeys.trading:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _loadContext(
|
|
String firebaseUid,
|
|
String pipelineKey,
|
|
) async {
|
|
final Map<String, dynamic>? state =
|
|
await _questionsDb.getPipelineState(firebaseUid);
|
|
if (state == null || state['pipelineKey'] != pipelineKey) {
|
|
return <String, dynamic>{};
|
|
}
|
|
return Map<String, dynamic>.from(
|
|
state['context'] as Map<String, dynamic>? ?? <String, dynamic>{},
|
|
);
|
|
}
|
|
|
|
Future<void> _startRootPipeline(String firebaseUid) async {
|
|
if (!await _canEnqueue(firebaseUid)) {
|
|
return;
|
|
}
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.root,
|
|
step: PipelineSteps.chooseTrack,
|
|
context: <String, dynamic>{},
|
|
);
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText:
|
|
'Which topics interest you? Swipe toward +10 for weather questions, '
|
|
'or toward -10 for geography. Submit your preference.',
|
|
correctAnswer: 0,
|
|
sourceTag: 'pipeline:root:choose_track',
|
|
pipelineKey: PipelineKeys.root,
|
|
pipelineStep: PipelineSteps.chooseTrack,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleRootAnswer({
|
|
required String firebaseUid,
|
|
required String step,
|
|
required num userResponse,
|
|
}) async {
|
|
if (step != PipelineSteps.chooseTrack) {
|
|
return;
|
|
}
|
|
|
|
final BranchOutcome outcome = BranchDecision.trackPreference(userResponse);
|
|
if (outcome == BranchOutcome.preferHigh) {
|
|
await _startWeatherPipeline(firebaseUid);
|
|
} else {
|
|
await _startGeographyPipeline(firebaseUid);
|
|
}
|
|
}
|
|
|
|
Future<void> _startGeographyPipeline(String firebaseUid) async {
|
|
if (!await _canEnqueue(firebaseUid)) {
|
|
return;
|
|
}
|
|
final CountryFacts? country =
|
|
testMode ? null : await _fetcher.fetchRandomCountry();
|
|
if (!testMode && country == null) {
|
|
await _setIdle(firebaseUid, PipelineKeys.geography);
|
|
return;
|
|
}
|
|
|
|
final Map<String, dynamic> context = testMode
|
|
? <String, dynamic>{
|
|
'country': 'Testland',
|
|
'populationMillions': 40,
|
|
'capital': 'Test City',
|
|
}
|
|
: <String, dynamic>{
|
|
'country': country!.name,
|
|
'populationMillions': country.populationMillions,
|
|
'capital': country.capital,
|
|
};
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.geography,
|
|
step: PipelineSteps.populationQuiz,
|
|
context: context,
|
|
);
|
|
const int thresholdMillions = 30;
|
|
final bool overThreshold = (context['populationMillions'] as int) >=
|
|
thresholdMillions;
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText:
|
|
'Does ${context['country']} have a population over $thresholdMillions million? '
|
|
'Swipe toward +10 for yes, toward -10 for no.',
|
|
correctAnswer: overThreshold ? 10 : -10,
|
|
sourceTag: 'pipeline:geography:population',
|
|
pipelineKey: PipelineKeys.geography,
|
|
pipelineStep: PipelineSteps.populationQuiz,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleGeographyAnswer({
|
|
required String firebaseUid,
|
|
required String step,
|
|
required num userResponse,
|
|
required num correctAnswer,
|
|
required Map<String, dynamic> context,
|
|
}) async {
|
|
final String country = context['country'] as String? ?? 'this country';
|
|
final String capital = context['capital'] as String? ?? '';
|
|
|
|
switch (step) {
|
|
case PipelineSteps.populationQuiz:
|
|
final BranchOutcome outcome = _evaluateAnswer(
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
production: () => BranchDecision.yesNo(
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
),
|
|
);
|
|
if (outcome == BranchOutcome.match) {
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.geography,
|
|
step: PipelineSteps.capitalFollowUp,
|
|
context: context,
|
|
);
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText:
|
|
'Nice estimate! What is the capital of $country? '
|
|
'Enter 1 if it is $capital, or -1 if you are unsure.',
|
|
correctAnswer: 1,
|
|
sourceTag: 'pipeline:geography:capital',
|
|
pipelineKey: PipelineKeys.geography,
|
|
pipelineStep: PipelineSteps.capitalFollowUp,
|
|
);
|
|
} else {
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.geography,
|
|
step: PipelineSteps.capitalRecovery,
|
|
context: context,
|
|
);
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText:
|
|
'The population of $country is about '
|
|
'${context['populationMillions']} million. '
|
|
'Try again: enter 1 if the capital is $capital, -1 otherwise.',
|
|
correctAnswer: 1,
|
|
sourceTag: 'pipeline:geography:recovery',
|
|
pipelineKey: PipelineKeys.geography,
|
|
pipelineStep: PipelineSteps.capitalRecovery,
|
|
);
|
|
}
|
|
case PipelineSteps.capitalFollowUp:
|
|
case PipelineSteps.capitalRecovery:
|
|
await _setIdle(firebaseUid, PipelineKeys.geography);
|
|
}
|
|
}
|
|
|
|
Future<void> _startWeatherPipeline(String firebaseUid) async {
|
|
if (!await _canEnqueue(firebaseUid)) {
|
|
return;
|
|
}
|
|
final WeatherFacts? weather =
|
|
testMode ? null : await _fetcher.fetchRandomCityWeather();
|
|
if (!testMode && weather == null) {
|
|
await _setIdle(firebaseUid, PipelineKeys.weather);
|
|
return;
|
|
}
|
|
|
|
final Map<String, dynamic> context = testMode
|
|
? <String, dynamic>{'city': 'Test City', 'temperatureCelsius': 20}
|
|
: <String, dynamic>{
|
|
'city': weather!.city,
|
|
'temperatureCelsius': weather.temperatureCelsius,
|
|
};
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.weather,
|
|
step: PipelineSteps.temperatureQuiz,
|
|
context: context,
|
|
);
|
|
const int warmThresholdC = 15;
|
|
final bool isWarm =
|
|
(context['temperatureCelsius'] as int) >= warmThresholdC;
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText:
|
|
'Is it $warmThresholdC°C or warmer in ${context['city']} right now? '
|
|
'Swipe toward +10 for yes, toward -10 for no.',
|
|
correctAnswer: isWarm ? 10 : -10,
|
|
sourceTag: 'pipeline:weather:temperature',
|
|
pipelineKey: PipelineKeys.weather,
|
|
pipelineStep: PipelineSteps.temperatureQuiz,
|
|
);
|
|
}
|
|
|
|
Future<void> _handleWeatherAnswer({
|
|
required String firebaseUid,
|
|
required String step,
|
|
required num userResponse,
|
|
required num correctAnswer,
|
|
required Map<String, dynamic> context,
|
|
}) async {
|
|
final String city = context['city'] as String? ?? 'the city';
|
|
final int actual = (context['temperatureCelsius'] as num?)?.round() ?? 0;
|
|
|
|
switch (step) {
|
|
case PipelineSteps.temperatureQuiz:
|
|
final BranchOutcome outcome = _evaluateAnswer(
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
production: () => BranchDecision.yesNo(
|
|
userResponse: userResponse,
|
|
correctAnswer: correctAnswer,
|
|
),
|
|
);
|
|
final String followUp = switch (outcome) {
|
|
BranchOutcome.match =>
|
|
'Spot on! It is about $actual°C in $city. '
|
|
'Enter +1 to get another weather question, -1 to switch to geography.',
|
|
BranchOutcome.close =>
|
|
'Close — it is about $actual°C in $city. '
|
|
'Enter +1 for another weather round, -1 for geography instead.',
|
|
_ =>
|
|
'It is about $actual°C in $city right now. '
|
|
'Enter +1 to try another city, -1 to try geography questions.',
|
|
};
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: PipelineKeys.weather,
|
|
step: PipelineSteps.temperatureFollowUp,
|
|
context: context,
|
|
);
|
|
await _deliverPipelineQuestion(
|
|
firebaseUid: firebaseUid,
|
|
questionText: followUp,
|
|
correctAnswer: 1,
|
|
sourceTag: 'pipeline:weather:follow_up',
|
|
pipelineKey: PipelineKeys.weather,
|
|
pipelineStep: PipelineSteps.temperatureFollowUp,
|
|
);
|
|
case PipelineSteps.temperatureFollowUp:
|
|
if (userResponse > 0) {
|
|
await _startWeatherPipeline(firebaseUid);
|
|
} else {
|
|
await _startGeographyPipeline(firebaseUid);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _advanceFromIdle(
|
|
String firebaseUid,
|
|
Map<String, dynamic> state,
|
|
) async {
|
|
final String pipelineKey = state['pipelineKey'] as String;
|
|
switch (pipelineKey) {
|
|
case PipelineKeys.geography:
|
|
await _startGeographyPipeline(firebaseUid);
|
|
case PipelineKeys.weather:
|
|
await _startWeatherPipeline(firebaseUid);
|
|
default:
|
|
await _startRootPipeline(firebaseUid);
|
|
}
|
|
}
|
|
|
|
Future<void> _setIdle(String firebaseUid, String pipelineKey) async {
|
|
await _questionsDb.upsertPipelineState(
|
|
assignedUserId: firebaseUid,
|
|
pipelineKey: pipelineKey,
|
|
step: PipelineSteps.idle,
|
|
context: await _loadContext(firebaseUid, pipelineKey),
|
|
);
|
|
}
|
|
|
|
void close() => _fetcher.close();
|
|
}
|