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 runMaintenanceCycle() async { final List 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 _maintainUser(String firebaseUid) async { final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid); if (queued >= maxQueuedQuestions) { return; } final Map? 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 _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 _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 onAnswerSubmitted({ required String firebaseUid, required Map 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 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> _loadContext( String firebaseUid, String pipelineKey, ) async { final Map? state = await _questionsDb.getPipelineState(firebaseUid); if (state == null || state['pipelineKey'] != pipelineKey) { return {}; } return Map.from( state['context'] as Map? ?? {}, ); } Future _startRootPipeline(String firebaseUid) async { if (!await _canEnqueue(firebaseUid)) { return; } await _questionsDb.upsertPipelineState( assignedUserId: firebaseUid, pipelineKey: PipelineKeys.root, step: PipelineSteps.chooseTrack, context: {}, ); 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 _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 _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 context = testMode ? { 'country': 'Testland', 'populationMillions': 40, 'capital': 'Test City', } : { '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 _handleGeographyAnswer({ required String firebaseUid, required String step, required num userResponse, required num correctAnswer, required Map 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 _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 context = testMode ? {'city': 'Test City', 'temperatureCelsius': 20} : { '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 _handleWeatherAnswer({ required String firebaseUid, required String step, required num userResponse, required num correctAnswer, required Map 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 _advanceFromIdle( String firebaseUid, Map 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 _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(); }