Add study session goals and summaries

This commit is contained in:
hz
2026-08-01 02:34:58 -05:00
parent ddc36a8048
commit 2bd2628009
27 changed files with 926 additions and 21 deletions
@@ -197,6 +197,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
request_failed.emit("The simulation API returned an invalid response.")
return
CreditsService.apply_award_response(parsed)
StudySessionService.apply_response(parsed)
if action == "readiness":
readiness = parsed
readiness_changed.emit()
@@ -437,6 +437,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
"presented_choice_ids": presented_choice_ids.duplicate(),
"duration_seconds": maxi(0, duration_seconds),
"met_time_limit": met_time_limit,
"study_session_id": StudySessionService.active_session_id,
"retry_count": 0
})
_save_attempt_queue()
@@ -472,6 +473,11 @@ func _process_attempt_queue() -> void:
"durationSeconds": int(queued.get("duration_seconds", 0)),
"metTimeLimit": bool(queued.get("met_time_limit", true))
})
var study_session_id := str(queued.get("study_session_id", ""))
if not study_session_id.is_empty():
var decoded: Dictionary = JSON.parse_string(body)
decoded["studySessionId"] = study_session_id
body = JSON.stringify(decoded)
if str(queued.get("kind", "answer")) == "skip":
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
body = ""
@@ -546,6 +552,8 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
_update_question_elo(question_id, question_elo)
_apply_rating_change(rating)
CreditsService.apply_award_response(parsed)
# Let Tutor users inspect the final answer before the summary replaces the player.
StudySessionService.apply_response(parsed, true)
choice_percentages_updated.emit(question_id, percentages)
question_rating_updated.emit(question_id, question_elo, rating)
+10 -2
View File
@@ -25,6 +25,7 @@ var retained_count := 0
var outbox: Array = []
var active_account_key := ""
var step3_system := ""
var target_count := 20
func _ready() -> void:
@@ -49,8 +50,9 @@ func _ready() -> void:
_on_auth_changed()
func start_pass(requested_step3_system: String = "") -> void:
func start_pass(requested_step3_system: String = "", requested_count: int = 20) -> void:
step3_system = requested_step3_system
target_count = clampi(requested_count, 1, 20)
excluded_question_ids.clear()
reviewed_count = 0
cleared_count = 0
@@ -59,6 +61,10 @@ func start_pass(requested_step3_system: String = "") -> void:
func load_next() -> void:
if reviewed_count >= target_count:
StudySessionService.emit_deferred_summary()
pass_completed.emit(get_summary())
return
if loading:
return
if not AuthState.can_send_authenticated_request():
@@ -99,7 +105,8 @@ func record_first_submission(question: Dictionary, choice_id: String, presented_
"cycle": current_cycle,
"choiceId": choice_id,
"presentedChoiceIds": presented_choice_ids.duplicate(),
"durationSeconds": clampi(duration_seconds, 0, 86400)
"durationSeconds": clampi(duration_seconds, 0, 86400),
"studySessionId": StudySessionService.active_session_id
}
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
_save_outbox()
@@ -191,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
var synced_is_correct := false
if typeof(parsed) == TYPE_DICTIONARY:
CreditsService.apply_award_response(parsed)
StudySessionService.apply_response(parsed, true)
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
+133
View File
@@ -0,0 +1,133 @@
extends Node
signal session_started
signal progress_changed
signal session_completed(summary: Dictionary)
signal session_failed(message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/study-sessions"
var api_url := DEFAULT_URL
var request: HTTPRequest
var active_session_id := ""
var progress: Dictionary = {}
var summary: Dictionary = {}
var pending_action := ""
var pending_launch: Dictionary = {}
func _ready() -> void:
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/study-sessions")
request = HTTPRequest.new()
request.timeout = 25.0
request.request_completed.connect(_on_completed)
add_child(request)
AuthState.auth_changed.connect(_on_auth_changed)
func start(goal_type: String, target_value: int, mode: String, step3_system: String = "", launch: Dictionary = {}) -> void:
if not AuthState.can_send_authenticated_request():
session_failed.emit("Login is required to start a study session.")
return
pending_launch = launch.duplicate(true)
summary = {}
_send("create", HTTPClient.METHOD_POST, "", {
"clientSessionId": _new_uuid(), "goalType": goal_type, "targetValue": target_value,
"mode": mode, "step3System": step3_system
})
func finish_early() -> void:
if active_session_id.is_empty():
return
_send("finish", HTTPClient.METHOD_POST, "/%s/finish" % active_session_id.uri_encode())
func check_completion() -> void:
if active_session_id.is_empty():
return
_send("check", HTTPClient.METHOD_POST, "/%s/check" % active_session_id.uri_encode())
func apply_response(parsed: Dictionary, defer_completion: bool = false) -> void:
var next_progress: Variant = parsed.get("studySessionProgress", null)
if typeof(next_progress) == TYPE_DICTIONARY:
progress = next_progress
active_session_id = str(progress.get("id", active_session_id))
progress_changed.emit()
var next_summary: Variant = parsed.get("sessionSummary", null)
if typeof(next_summary) == TYPE_DICTIONARY and not next_summary.is_empty():
summary = next_summary
active_session_id = ""
if not defer_completion:
session_completed.emit(summary)
func emit_deferred_summary() -> void:
if not summary.is_empty():
session_completed.emit(summary)
func is_active() -> bool:
return not active_session_id.is_empty()
func seconds_remaining() -> int:
return int(progress.get("secondsRemaining", 0))
func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void:
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return
pending_action = action
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
var body := ""
if not payload.is_empty():
headers.append("Content-Type: application/json")
body = JSON.stringify(payload)
var error := request.request(api_url + suffix, headers, method, body)
if error != OK:
pending_action = ""
session_failed.emit("The study session could not be started.")
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var action := pending_action
pending_action = ""
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
session_failed.emit("The study session request failed.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
session_failed.emit("The study session response was invalid.")
return
var next_progress: Dictionary = parsed.get("progress", {})
progress = next_progress
active_session_id = str(progress.get("id", "")) if str(progress.get("state", "")) == "active" else ""
var next_summary: Variant = parsed.get("summary", null)
if typeof(next_summary) == TYPE_DICTIONARY:
summary = next_summary
progress_changed.emit()
if action == "create":
session_started.emit()
elif not summary.is_empty():
session_completed.emit(summary)
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
active_session_id = ""
progress = {}
summary = {}
func _new_uuid() -> String:
var bytes := Crypto.new().generate_random_bytes(16)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var value := bytes.hex_encode()
return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)]
@@ -0,0 +1 @@
uid://da63hc4qgjlso