cyberhybridhub/server/lib/pipeline/question_pipeline.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();
}