cyberhybridhub/server/lib/question_service.dart
2026-06-03 04:21:42 -05:00

188 lines
6.2 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'questions_db.dart';
import 'signalr/questions_hub_connections.dart';
import 'trading/prospective_guess_selection.dart';
import 'trading/prospective_guess_assignments_db.dart';
import 'trading/market_history_question_audit.dart';
/// Creates questions in Postgres and delivers them over SignalR.
class QuestionService {
QuestionService({
required QuestionsDb questionsDb,
required QuestionsHubConnections hubConnections,
}) : _questionsDb = questionsDb,
_hubConnections = hubConnections;
final QuestionsDb _questionsDb;
final QuestionsHubConnections _hubConnections;
ProspectiveGuessAssignmentsDb get _assignmentsDb =>
ProspectiveGuessAssignmentsDb(_questionsDb.connection);
/// Called at login: ensures one unanswered prospective question when possible.
Future<Map<String, dynamic>?> bootstrapOnLogin(
String firebaseUid,
) async {
await _questionsDb.ensureUserExists(firebaseUid);
return ensureProspectiveQuestionQueued(firebaseUid);
}
/// Ensures the active slot's top-half batch exists, then returns the FIFO head.
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
String firebaseUid, {
DateTime? now,
}) async {
await _questionsDb.ensureUserExists(firebaseUid);
await _ensureCurrentSlotBatch(firebaseUid, now: now);
final int queued =
await _questionsDb.countUnansweredQuestions(firebaseUid);
if (queued > 0) {
final Map<String, dynamic>? existing =
await _questionsDb.findUnansweredQuestion(firebaseUid);
if (existing == null) {
return null;
}
return _questionsDb.toClientPayload(
existing,
unansweredCount: queued,
);
}
return null;
}
/// Creates every top-half question for the user's active slot pair.
Future<void> _ensureCurrentSlotBatch(
String firebaseUid, {
DateTime? now,
}) async {
final ProspectiveGuessSelection selection = ProspectiveGuessSelection(
connection: _questionsDb.connection,
);
for (var attempt = 0; attempt < 8; attempt++) {
final ProspectiveSlotBatch? batch =
await selection.resolveUnassignedBatch(firebaseUid, now: now);
if (batch == null || batch.unassignedAssets.isEmpty) {
return;
}
final DateTime baseOrder = DateTime.now().toUtc();
var createdAny = false;
for (var index = 0; index < batch.unassignedAssets.length; index++) {
final QuestionAuditAsset asset = batch.unassignedAssets[index];
if (await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
symbol: asset.symbol,
)) {
continue;
}
final Map<String, dynamic> picked = await selection.buildPickForAsset(
asset: asset,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
);
final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: firebaseUid,
questionText: picked['questionText']! as String,
correctAnswer: picked['correctAnswer']! as num,
sourceTag: 'market_history:prospective',
pipelineKey: 'trading',
pipelineStep: 'guess_weekly_move:await_answer',
metadata: <String, dynamic>{
'prospective_question_id': picked['id'],
'symbol': asset.symbol,
'older_slot_start': picked['olderSlotStart'],
'newer_slot_start': picked['newerSlotStart'],
'price_delta_pct': picked['priceDeltaPct'],
},
);
final bool assigned = await _assignmentsDb.insertPendingIfAbsent(
firebaseUid: firebaseUid,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
symbol: asset.symbol,
prospectiveQuestionId: picked['id']! as String,
questionId: question['id']! as String,
viewOrderAt: baseOrder.add(Duration(milliseconds: index)),
);
if (!assigned) {
await _questionsDb.deleteUnansweredQuestion(
questionId: question['id']! as String,
assignedUserId: firebaseUid,
);
continue;
}
createdAny = true;
}
if (createdAny) {
return;
}
}
}
/// Inserts a question and pushes it to connected SignalR clients.
Future<Map<String, dynamic>> createAndDeliverQuestion({
required String assignedUserId,
required String questionText,
required num correctAnswer,
String? sourceTag,
String? pipelineKey,
String? pipelineStep,
Map<String, dynamic>? metadata,
}) async {
final Map<String, dynamic> question = await _questionsDb.createQuestion(
assignedUserId: assignedUserId,
questionText: questionText,
correctAnswer: correctAnswer,
sourceTag: sourceTag,
pipelineKey: pipelineKey,
pipelineStep: pipelineStep,
metadata: metadata,
);
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(assignedUserId);
final Map<String, dynamic> payload = _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount,
);
await _hubConnections.pushQuestion(assignedUserId, payload);
return payload;
}
/// On SignalR connect: deliver an existing unanswered question only (no create).
Future<void> deliverPendingQuestionOnConnect(
QuestionsHubConnection connection,
) async {
try {
final String uid = connection.firebaseUid;
final Map<String, dynamic>? question =
await _questionsDb.findUnansweredQuestion(uid);
if (question == null) {
return;
}
final int unansweredCount =
await _questionsDb.countUnansweredQuestions(uid);
final Map<String, dynamic> payload = _questionsDb.toClientPayload(
question,
unansweredCount: unansweredCount,
);
await _hubConnections.pushQuestionToConnection(connection, payload);
} catch (e, st) {
stderr.writeln(
'Failed to deliver pending question for ${connection.firebaseUid}: $e\n$st',
);
}
}
}