C
File size: 29,982 Bytes
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a25ff64
 
 
 
 
 
 
 
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5e44fd
f5f3917
152dcfe
 
 
 
 
f5f3917
 
 
 
 
2cf117d
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5e44fd
f5f3917
 
 
 
 
2cf117d
f5f3917
 
 
 
 
 
81f0f87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5e44fd
81f0f87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cf117d
 
 
 
 
 
 
 
 
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5e44fd
 
 
 
 
f5f3917
 
 
 
 
a5e44fd
f5f3917
 
a25ff64
f5f3917
 
 
 
 
 
2cf117d
f5f3917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
"""HF dataset logger, schema 1.2.

Schema 1.2 (socratic v4 — plan-complete turn type, end-session flush,
upload error capture, derived session summary):
  {
    schema_version: "1.2",
    session_id, condition, run_mode,
    started_at, ended_at,
    model, case_id,
    perfect_answer: {text, generated_at, char_count} or null,
    discussion_plan: {text, extracted_at} or null,
    manager_history: [verdict dicts, one per bot turn],
    turns: [
      {
        n, timestamp,
        user_message,
        manager_verdict: {live_subprobe_id, advanced_this_turn, turn_type,
                          directive, reason, latency_ms},
        plan_control: <verbatim PLAN CONTROL text injected into main bot>,
        assistant_response,
        scratch: {chosen_target, response_summary, directive_followed},
        latency_s: {ttft, total}
      }, ...
    ],
    feedback: [...],
    feedback_current: {message_index_str: latest_kind},
    argument_snapshots: [...],
    essays: {argument: {text, submitted_at, char_count}},
    upload_errors: [<error strings from failed upload attempts>],
    session_summary: {
      n_turns, n_transitions, n_plan_completes,
      subprobe_coverage: {sub_id: {engaging_default: K, other: M}, ...},
      last_subprobe_visited, total_subprobes_in_plan,
      outcome: "plan-complete-reached" | "ran-without-plan-complete",
    }
  }

What changed from 1.1:
  - schema_version: bumped to 1.2.
  - upload_errors: NEW top-level list; persists upload-failure messages so a
    truncated log shows WHY it truncated when re-uploaded.
  - session_summary: NEW top-level derived block, computed at every
    _build_payload, so coverage / transition / plan-complete stats are
    available without re-deriving from manager_history.
  - plan-complete: new valid turn_type (handled transparently in turns[]
    and manager_history; counted in session_summary).
  - End-of-session flush: SessionLogger.flush() does a synchronous upload
    with retries, called by app.on_end_session in the CONFIRMED branch so
    the final payload always lands in the dataset before the UI thanks the
    participant.
  - Upload retries: _upload now retries 3 times with exponential backoff
    (1s, 2s, 4s) before giving up and recording the error.

Path: {HF_DATASET}/{LOG_PATH}/{session_id}.json
"""

import io
import json
import queue
import secrets
import sys
import threading
import time
from datetime import datetime, timezone, timedelta
from core import config_loader
from core.config_loader import (
    HF_DATASET, MODEL, LOG_PATH,
    CONDITION, SCHEMA_VERSION, CASE_ID, RUN_MODE,
)

AEST = timezone(timedelta(hours=10))


def now_aest():
    """Wall-clock datetime in AEST. Single source of truth so display strings
    in app.py and logger timestamps stay aligned."""
    return datetime.now(AEST)


def _now_iso():
    return now_aest().isoformat()


def _unix_to_iso(unix_ts):
    """Convert unix epoch seconds (from time.time()) to AEST ISO 8601 string.
    Returns None if input is None."""
    if unix_ts is None:
        return None
    return datetime.fromtimestamp(unix_ts, tz=AEST).isoformat()


# Completion-code character set. Digits only (0-9) — short, unambiguous,
# easy for participants to type. 10^5 = 100,000 combinations — collision-
# safe for any study size in the hundreds.
_CODE_ALPHABET = "0123456789"


def _make_completion_code():
    """Generate a short, human-friendly completion code.
    Embedded verbatim in the log filename (as the digit suffix) and stored
    as a top-level field. The end-of-session modal shows the participant
    a presentation string built from this code (see _completion_modal_html
    in app.py) — the prefix decoration is added at display time and is NOT
    part of the stored code."""
    return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(5))


class SessionLogger:
    """Schema 1.1 session log. Coalesces uploads via a single background
    worker thread + 1-slot queue: a burst of N writes produces at most ~2
    actual HF uploads (first + final) instead of N redundant overwrites."""

    def __init__(self):
        self.session_id = None
        self.completion_code = None    # 5-char code shown to participant at end
        self.assigned_key_idx = None   # 1-10 (pool key) / 0 (fallback API_KEY) / None
        self.start_time = None
        self.walkthrough_started_at = None  # epoch seconds (time.time()) when
                                             # participant clicked "Start
                                             # walkthrough" on the welcome
                                             # overlay; set ONCE, preserved
                                             # across start_session() resets.
        self.turns = []
        self.essays = {}                # {"argument": {...}}
        self.feedback = []
        self.perfect_answer = None
        self.discussion_plan = None
        self.dominant_framework = None  # one of: consequence, duty_rights, character
        self.argument_snapshots = []
        self.manager_history = []       # cumulative verdict dicts
        self.upload_errors = []         # accumulated upload-failure messages
        self._upload_queue = queue.Queue(maxsize=1)
        self._upload_worker = None
        self._upload_lock = threading.Lock()  # serializes flush vs background worker
        # State tracking for verify-after-upload + recovery backup.
        # _last_confirmed_state is the state-hash of the most recent payload
        # that was uploaded AND verified to exist on HF. If at flush time
        # _last_confirmed_state lags current state, we write a recovery file.
        self._last_confirmed_state = None

    def start_session(self):
        self.start_time = now_aest()
        self.session_id = self.start_time.strftime("%Y%m%d_%H%M%S")
        self.completion_code = _make_completion_code()
        self.assigned_key_idx = None
        self.turns = []
        self.essays = {}
        self.feedback = []
        self.perfect_answer = None
        self.discussion_plan = None
        self.dominant_framework = None
        self.argument_snapshots = []
        self.manager_history = []
        self.upload_errors = []
        self._last_confirmed_state = None
        self._ensure_worker()

    @classmethod
    def from_dict(cls, data):
        """Rebuild a SessionLogger from a parsed HF JSON payload, used by the
        on_recover_session handler when a participant returns to a session
        whose tab was closed before End Session.

        Preserves the original session_id + completion_code so subsequent
        uploads land in the SAME HF file. All accumulated state (essays,
        perfect_answer, discussion_plan, dominant_framework, feedback,
        argument_snapshots, upload_errors) is restored verbatim. turns and
        manager_history are typically empty in the no_ai condition (reading
        only, no chat) but restored for symmetry with chat-based spaces.

        Returns the logger, OR None if the payload is missing the minimum
        fields (session_id + completion_code) — caller falls back to fresh.
        """
        if not isinstance(data, dict):
            return None
        session_id = data.get("session_id")
        completion_code = data.get("completion_code")
        if not session_id or not completion_code:
            return None

        logger = cls()
        logger.session_id = session_id
        logger.completion_code = completion_code
        logger.assigned_key_idx = data.get("assigned_key_idx")

        started_at_str = data.get("started_at")
        try:
            logger.start_time = (
                datetime.fromisoformat(started_at_str)
                if started_at_str else now_aest()
            )
        except (ValueError, TypeError):
            logger.start_time = now_aest()

        # walkthrough_started_at is the only unix-epoch field on the no_ai
        # logger (no argument_submitted_at gate in this condition).
        v = data.get("walkthrough_started_at")
        if v:
            try:
                logger.walkthrough_started_at = (
                    datetime.fromisoformat(v).timestamp()
                )
            except (ValueError, TypeError):
                pass

        logger.turns = list(data.get("turns") or [])
        logger.manager_history = list(data.get("manager_history") or [])
        logger.argument_snapshots = list(data.get("argument_snapshots") or [])
        logger.feedback = list(data.get("feedback") or [])
        logger.essays = dict(data.get("essays") or {})
        logger.perfect_answer = data.get("perfect_answer")
        logger.discussion_plan = data.get("discussion_plan")
        logger.dominant_framework = data.get("dominant_framework")
        logger.upload_errors = list(data.get("upload_errors") or [])

        try:
            latest_arg = (logger.essays.get("argument") or {}).get("text", "")
            logger.argument_snapshots.append({
                "n": len(logger.argument_snapshots) + 1,
                "timestamp": _now_iso(),
                "trigger": "session_resumed",
                "text": latest_arg,
                "char_count": len(latest_arg),
                "changed_since_prev": False,
            })
        except Exception:
            pass

        logger._last_confirmed_state = logger._state_hash()
        logger._ensure_worker()
        logger._enqueue_upload()
        return logger

    def _filename(self):
        """Filename written to HF dataset for this session. Embeds both the
        session timestamp (for chronological sorting) and the completion code
        (for joining sessions to Qualtrics responses by code)."""
        if not self.session_id:
            return None
        if self.completion_code:
            return f"{LOG_PATH}/{self.session_id}_{self.completion_code}.json"
        return f"{LOG_PATH}/{self.session_id}.json"

    def _state_hash(self):
        """Tuple snapshot of how much data has been accumulated this session.
        Used to compare 'what we last successfully uploaded' against 'what we
        have now'. Monotonically grows; bigger tuple = newer state."""
        return (
            len(self.turns),
            len(self.manager_history),
            len(self.argument_snapshots),
            len(self.feedback),
        )

    def _ensure_worker(self):
        if self._upload_worker is None or not self._upload_worker.is_alive():
            self._upload_worker = threading.Thread(
                target=self._upload_loop, daemon=True
            )
            self._upload_worker.start()

    def set_essay(self, key, text):
        if not self.session_id:
            self.start_session()
        self.essays[key] = {
            "text": text,
            "submitted_at": _now_iso(),
            "char_count": len(text),
        }
        self._enqueue_upload()

    def set_discussion_plan(self, text):
        if not self.session_id:
            return
        self.discussion_plan = {
            "text": text,
            "extracted_at": _now_iso(),
        }
        self._enqueue_upload()

    def set_perfect_answer(self, text):
        if not self.session_id:
            return
        self.perfect_answer = {
            "text": text,
            "generated_at": _now_iso(),
            "char_count": len(text),
        }
        self._enqueue_upload()

    def set_dominant_framework(self, framework):
        """Record which of the 3 frameworks (consequence / duty_rights /
        character) the perfect-answer generator chose for this session.
        Stored for log analysis."""
        if not self.session_id:
            return
        self.dominant_framework = framework
        self._enqueue_upload()

    def add_feedback(self, message_index, kind, message_excerpt=""):
        """Record a participant aha! / ugh. reaction on a bot message.

        kind: "aha" (made something click),
              "stress" (made participant uncomfortable),
              "none" (cancel, second click on the already-active emoji).
        Every click (including cancels) is appended with its own timestamp.
        """
        if not self.session_id:
            return
        self.feedback.append({
            "n": len(self.feedback) + 1,
            "message_index": message_index,
            "kind": kind,
            "timestamp": _now_iso(),
            "message_excerpt": (message_excerpt or "")[:200],
        })
        self._enqueue_upload()

    def get_current_kind(self, message_index):
        """Return the current ('aha' / 'stress' / None) reaction for a given
        message index, derived from the latest event for that index."""
        for event in reversed(self.feedback):
            if event.get("message_index") == message_index:
                k = event.get("kind")
                return None if k == "none" else k
        return None

    def add_argument_snapshot(self, trigger, text, context=None):
        """Snapshot the participant's notepad textbox at an interaction event.
        The bot NEVER sees this; logger-only side-channel."""
        if not self.session_id:
            return
        text = text or ""
        prev = self.argument_snapshots[-1] if self.argument_snapshots else None
        snap = {
            "n": len(self.argument_snapshots) + 1,
            "timestamp": _now_iso(),
            "trigger": trigger,
            "text": text,
            "char_count": len(text),
            "changed_since_prev": (prev["text"] != text) if prev else None,
        }
        if context:
            snap["context"] = context
        self.argument_snapshots.append(snap)
        self._enqueue_upload()

    def add_manager_verdict(self, verdict_dict):
        """Append a manager verdict to the session-level history list.
        Called once per chat turn, right after the manager LLM returns.

        `verdict_dict` should be the dict form of `ManagerVerdict.to_dict()`.
        """
        if not self.session_id:
            return
        self.manager_history.append(dict(verdict_dict))
        self._enqueue_upload()

    def add_turn(self, user_message, manager_verdict, plan_control,
                 assistant_response, scratch=None, latency_s=None):
        """Append a chat turn. All fields together describe one full round:
        what the participant said, what the manager decided, what was
        injected into the main bot, what the main bot said back."""
        if not self.session_id:
            self.start_session()
        turn = {
            "n": len(self.turns) + 1,
            "timestamp": _now_iso(),
            "user_message": user_message,
            "manager_verdict": dict(manager_verdict) if manager_verdict else None,
            "plan_control": plan_control,
            "assistant_response": assistant_response,
        }
        if scratch:
            turn["scratch"] = dict(scratch)
        if latency_s:
            turn["latency_s"] = latency_s
        self.turns.append(turn)
        self._enqueue_upload()

    def _derive_feedback_current(self):
        """Collapse feedback events to {message_index: latest_kind},
        omitting messages whose latest event is a cancel ('none')."""
        latest = {}
        for event in self.feedback:
            idx = event.get("message_index")
            if idx is None:
                continue
            latest[idx] = event.get("kind")
        return {idx: k for idx, k in latest.items() if k and k != "none"}

    def _derive_session_summary(self):
        """Compute coverage / transition / plan-complete stats from
        manager_history. Returns None if no manager turns yet."""
        if not self.manager_history:
            return None
        by_sub = {}
        n_transitions = 0
        n_plan_completes = 0
        for v in self.manager_history:
            sub = v.get("live_subprobe_id")
            tt = v.get("turn_type")
            if sub is not None:
                if sub not in by_sub:
                    by_sub[sub] = {"engaging_default": 0, "other": 0}
                if tt == "engaging-default":
                    by_sub[sub]["engaging_default"] += 1
                else:
                    by_sub[sub]["other"] += 1
            if v.get("advanced_this_turn"):
                n_transitions += 1
            if tt == "plan-complete":
                n_plan_completes += 1
        # Total sub-probes in the plan, derived from discussion_plan text.
        total_in_plan = None
        if self.discussion_plan and self.discussion_plan.get("text"):
            import re
            ids = re.findall(
                r"^\s*-\s*id\s*:\s*(\d+)",
                self.discussion_plan["text"],
                re.IGNORECASE | re.MULTILINE,
            )
            if ids:
                total_in_plan = max(int(x) for x in ids)
        outcome = (
            "plan-complete-reached" if n_plan_completes > 0
            else "ran-without-plan-complete"
        )
        return {
            "n_turns": len(self.turns),
            "n_manager_verdicts": len(self.manager_history),
            "n_transitions": n_transitions,
            "n_plan_completes": n_plan_completes,
            "subprobe_coverage": by_sub,
            "last_subprobe_visited": max(by_sub) if by_sub else None,
            "total_subprobes_in_plan": total_in_plan,
            "outcome": outcome,
        }

    def set_assigned_key(self, key_idx):
        """Record which OpenAI key index was assigned to this session
        (1-10 for pool keys, 0 for fallback API_KEY)."""
        self.assigned_key_idx = key_idx

    def _build_payload(self):
        return {
            "schema_version": SCHEMA_VERSION,
            "session_id": self.session_id,
            "completion_code": self.completion_code,
            "assigned_key_idx": self.assigned_key_idx,
            "condition": CONDITION,
            "run_mode": RUN_MODE,
            "walkthrough_started_at": _unix_to_iso(self.walkthrough_started_at),
            "started_at": self.start_time.isoformat() if self.start_time else None,
            "ended_at": _now_iso(),
            "model": MODEL,
            "case_id": CASE_ID,
            "perfect_answer": self.perfect_answer,
            "discussion_plan": self.discussion_plan,
            "dominant_framework": self.dominant_framework,
            "manager_history": list(self.manager_history),
            "turns": list(self.turns),
            "feedback": list(self.feedback),
            "feedback_current": self._derive_feedback_current(),
            "argument_snapshots": list(self.argument_snapshots),
            "essays": self.essays,
            "upload_errors": list(self.upload_errors),
            "session_summary": self._derive_session_summary(),
        }

    def _enqueue_upload(self):
        """Push the latest payload onto the upload queue. If a payload is
        already queued, it gets superseded (only the newest survives).

        Payload is built CUMULATIVELY (contains all turns so far). This is
        the natural 'defer on failure' mechanism: if T_n upload failed
        silently and _last_confirmed_state didn't advance, T_{n+1}'s payload
        still contains T_n's data and a retry will catch it up."""
        if not config_loader.hf_api or not self.session_id:
            return
        payload = self._build_payload()
        # Snapshot state hash at payload-build time so verify-after-upload
        # can mark the precise level of progress this upload represents.
        state_snapshot = self._state_hash()
        try:
            self._upload_queue.get_nowait()
        except queue.Empty:
            pass
        self._upload_queue.put((payload, state_snapshot))
        self._ensure_worker()

    def _upload_loop(self):
        while True:
            payload, state_snapshot = self._upload_queue.get()
            self._upload(payload, expected_state=state_snapshot)

    def _verify_uploaded(self, filename):
        """Lightweight verify-after-upload check. We don't trust upload_file's
        return alone because HF can return 200 OK but silently drop the commit
        under rate-limit or backend-conflict conditions. Confirm the file is
        actually present by listing the dataset's files.

        Returns True iff `filename` shows up in the dataset listing right after
        upload. False on any failure (network error, file missing).
        """
        try:
            files = config_loader.hf_api.list_repo_files(
                repo_id=HF_DATASET, repo_type="dataset"
            )
            return filename in files
        except Exception:
            return False

    def _upload(self, payload, max_retries=3, expected_state=None):
        """Upload payload to HF dataset with exponential backoff retry +
        verify-after-upload. Returns True on success, False if all retries
        failed or upload silently failed verification.

        `expected_state` is the _state_hash() snapshot taken at the moment
        this payload was built; if the upload succeeds AND verifies, we mark
        `_last_confirmed_state = expected_state` so the caller (or flush)
        can tell whether the most recent state has been committed to HF.

        On failure: records the error in self.upload_errors and prints to
        stderr; does NOT update _last_confirmed_state, which means the next
        cumulative upload (next turn) will naturally retry this turn's data.
        """
        last_err = None
        filename = self._filename()
        for attempt in range(max_retries):
            try:
                content = json.dumps(payload, ensure_ascii=False, indent=2)
                with self._upload_lock:
                    config_loader.hf_api.upload_file(
                        path_or_fileobj=io.BytesIO(content.encode("utf-8")),
                        path_in_repo=filename,
                        repo_id=HF_DATASET,
                        repo_type="dataset",
                    )
                # Verify the commit actually applied — HF can silently drop
                # uploads under rate pressure. If the file isn't visible
                # right after, treat as failure and retry.
                if self._verify_uploaded(filename):
                    if expected_state is not None:
                        self._last_confirmed_state = expected_state
                    return True
                last_err = "verify-after-upload failed (file not visible after commit)"
            except Exception as e:
                last_err = e
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt)  # 1s, 2s before final attempt
        err_str = (
            f"[{_now_iso()}] upload failed after {max_retries} attempts: "
            f"{type(last_err).__name__ if isinstance(last_err, Exception) else 'verify'}: {last_err}"
        )[:500]
        self.upload_errors.append(err_str)
        print(f"[logger] {err_str}", file=sys.stderr, flush=True)
        return False

    def _upload_recovery_backup(self, payload):
        """End-of-session safety net: if the primary upload couldn't be
        confirmed even after the extended retry schedule in flush(), write a
        clearly-named recovery file to a distinct sub-folder. Naming:

            <LOG_PATH>/_recovery/<session_id>.json

        The `_recovery/` prefix sorts separately from normal logs so an
        analyst can immediately see 'these sessions had upload trouble'.
        Inside the file, we tag the payload with a `__recovery_backup__`
        marker noting reason + intended primary path + timestamp.

        Uses the SAME extended backoff schedule as the primary flush
        (2,4,8,16,30,45,60s, ~165s worst case), since this is the absolute
        last line of defence — give HF every chance to accept the write.
        """
        suffix = (f"{self.session_id}_{self.completion_code}.json"
                  if self.completion_code else f"{self.session_id}.json")
        backup_path = f"{LOG_PATH}/_recovery/{suffix}"
        primary_path = f"{LOG_PATH}/{suffix}"
        backup_payload = dict(payload)
        backup_payload["__recovery_backup__"] = {
            "reason": "primary upload could not be confirmed before end_session",
            "written_at": _now_iso(),
            "primary_path": primary_path,
            "last_confirmed_state": self._last_confirmed_state,
            "current_state": self._state_hash(),
        }
        last_err = None
        # Extended backoff schedule matching flush() primary path.
        backoffs = [0, 2, 4, 8, 16, 30, 45]
        for i, wait in enumerate(backoffs):
            if wait > 0:
                time.sleep(wait)
            try:
                content = json.dumps(backup_payload, ensure_ascii=False, indent=2)
                with self._upload_lock:
                    config_loader.hf_api.upload_file(
                        path_or_fileobj=io.BytesIO(content.encode("utf-8")),
                        path_in_repo=backup_path,
                        repo_id=HF_DATASET,
                        repo_type="dataset",
                    )
                if self._verify_uploaded(backup_path):
                    print(f"[logger] recovery backup saved to {backup_path}",
                          file=sys.stderr, flush=True)
                    return True
                last_err = "backup verify-after-upload failed"
            except Exception as e:
                last_err = e
        print(f"[logger] CRITICAL: recovery backup ALSO failed for session "
              f"{self.session_id}: {last_err}", file=sys.stderr, flush=True)
        return False

    def flush(self):
        """End-of-session synchronous upload with extended retry budget.

        Designed for the case where per-turn uploads may have failed silently
        during the conversation (HF rate-limit / commit conflict under heavy
        concurrency). This is the LAST CHANCE to get the session data into
        the dataset, so we retry much harder than per-turn uploads:

          - Primary upload: up to 8 attempts with backoffs of
            2, 4, 8, 16, 30, 45, 60s (total ~165s worst case).
          - If primary still fails, write recovery backup to
            <LOG_PATH>/_recovery/<session_id>.json with 7 attempts at the
            same backoff schedule.

        Returns a status dict:
          {"status": "uploaded_primary",  "primary": True,  "recovery": False}
          {"status": "uploaded_recovery", "primary": False, "recovery": True}
          {"status": "all_failed",        "primary": False, "recovery": False}
          {"status": "no_logger",         "primary": False, "recovery": False}

        UI handlers should display the status to the participant so they know
        whether their data made it. `session_id` is exposed so a researcher
        can recover later if all_failed."""
        if not config_loader.hf_api or not self.session_id:
            return {"status": "no_logger", "primary": False, "recovery": False,
                    "session_id": self.session_id,
                    "completion_code": self.completion_code}
        # Drain any pending async payload — the sync flush supersedes it.
        try:
            self._upload_queue.get_nowait()
        except queue.Empty:
            pass
        payload = self._build_payload()
        current_state = self._state_hash()
        # Extended backoff schedule for end-of-session: 2,4,8,16,30,45,60s.
        backoffs = [0, 2, 4, 8, 16, 30, 45, 60]
        primary_ok = False
        for i, wait in enumerate(backoffs):
            if wait > 0:
                time.sleep(wait)
            # Single-attempt path that respects verify but does NOT do its own
            # retries (we control retry timing here).
            if self._upload_single_attempt(payload, expected_state=current_state):
                primary_ok = True
                break
        if primary_ok:
            return {"status": "uploaded_primary", "primary": True, "recovery": False,
                    "session_id": self.session_id,
                    "completion_code": self.completion_code}
        # Primary still failed — write recovery backup with extended retries.
        recovery_ok = self._upload_recovery_backup(payload)
        if recovery_ok:
            return {"status": "uploaded_recovery", "primary": False, "recovery": True,
                    "session_id": self.session_id,
                    "completion_code": self.completion_code}
        return {"status": "all_failed", "primary": False, "recovery": False,
                "session_id": self.session_id,
                "completion_code": self.completion_code}

    def _upload_single_attempt(self, payload, expected_state=None):
        """One upload + verify, no internal retry. Caller controls retry timing.
        Used by flush() which has its own extended backoff schedule."""
        filename = self._filename()
        try:
            content = json.dumps(payload, ensure_ascii=False, indent=2)
            with self._upload_lock:
                config_loader.hf_api.upload_file(
                    path_or_fileobj=io.BytesIO(content.encode("utf-8")),
                    path_in_repo=filename,
                    repo_id=HF_DATASET,
                    repo_type="dataset",
                )
            if self._verify_uploaded(filename):
                if expected_state is not None:
                    self._last_confirmed_state = expected_state
                return True
            self.upload_errors.append(
                f"[{_now_iso()}] flush attempt: verify-after-upload failed"[:500]
            )
            return False
        except Exception as e:
            self.upload_errors.append(
                f"[{_now_iso()}] flush attempt: {type(e).__name__}: {e}"[:500]
            )
            print(f"[logger] flush attempt failed: {type(e).__name__}: {e}",
                  file=sys.stderr, flush=True)
            return False