File size: 48,929 Bytes
8149dc4
 
 
 
3b52a72
ed2d077
65c88f3
f683f98
4c45acd
ea9a277
c9c1343
040fb51
fd6236c
 
8149dc4
88c9820
acceb2a
ea9a277
d6af388
ea9a277
ed2d077
 
fb691f0
ea9a277
 
 
ed2d077
3b52a72
ed2d077
 
 
 
 
 
 
 
b6b10a1
ed2d077
3b52a72
ea9a277
d6af388
 
1d21087
8149dc4
88c9820
8149dc4
2fbb9b4
 
 
76578a8
 
2fbb9b4
76578a8
 
 
 
 
 
 
2fbb9b4
76578a8
2fbb9b4
 
76578a8
 
2fbb9b4
76578a8
 
 
 
2fbb9b4
76578a8
2fbb9b4
 
76578a8
 
2fbb9b4
76578a8
 
 
2fbb9b4
76578a8
2fbb9b4
 
76578a8
 
2fbb9b4
9e1a1a7
 
2fbb9b4
 
 
 
76578a8
 
2fbb9b4
76578a8
 
2fbb9b4
76578a8
8149dc4
 
3b52a72
8149dc4
76578a8
ea9a277
88c9820
3b52a72
fb691f0
22919c2
ea9a277
88c9820
ea9a277
22919c2
88c9820
 
f091e7c
040fb51
 
 
88c9820
f091e7c
4a8daec
040fb51
f091e7c
040fb51
 
 
f091e7c
88c9820
4a8daec
 
 
c8770eb
ea9a277
 
 
c8770eb
040fb51
22919c2
 
 
 
 
 
 
 
 
ea9a277
 
22919c2
 
 
 
 
 
 
 
 
 
 
 
 
88c9820
fb691f0
88c9820
 
 
 
 
 
65c88f3
88c9820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb691f0
88c9820
 
65c88f3
88c9820
 
 
 
 
 
 
 
 
 
fb691f0
88c9820
 
 
 
fb691f0
88c9820
22919c2
88c9820
 
 
 
 
 
 
 
22919c2
 
 
 
 
88c9820
22919c2
 
88c9820
22919c2
 
 
 
88c9820
22919c2
 
 
 
 
88c9820
fb691f0
88c9820
 
 
 
211f12c
88c9820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211f12c
88c9820
 
 
 
 
5f09703
ea9a277
 
5f09703
88c9820
 
 
 
5f09703
ea9a277
5f09703
 
 
 
ea9a277
 
88c9820
 
 
 
 
 
8149dc4
 
 
 
88c9820
2df06f5
f7df3ac
8149dc4
 
 
 
 
88c9820
f7df3ac
8149dc4
aaae23b
f7df3ac
aaae23b
 
 
 
 
8149dc4
03c4a10
 
 
 
 
 
8149dc4
 
 
 
13baacd
 
0fdadfb
 
8dabad2
0fdadfb
 
 
 
03c4a10
 
0fdadfb
 
e91ba59
0fdadfb
03c4a10
0fdadfb
 
 
 
 
 
8149dc4
 
88c9820
8149dc4
88c9820
 
 
 
76578a8
88c9820
8149dc4
88c9820
 
22be697
76578a8
88c9820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8149dc4
88c9820
76578a8
88c9820
 
 
 
 
 
 
8149dc4
 
 
76578a8
8149dc4
 
 
 
 
 
 
ea9a277
76578a8
8149dc4
 
 
 
 
 
 
 
 
 
 
 
 
13baacd
 
8149dc4
 
03c4a10
8149dc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c4a10
8149dc4
 
 
 
 
03c4a10
 
8149dc4
 
 
 
 
 
 
 
 
 
 
 
 
 
03c4a10
8149dc4
 
 
 
88c9820
8dabad2
 
88c9820
8dabad2
88c9820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da73095
88c9820
da73095
 
88c9820
da73095
 
 
 
 
 
 
fd6236c
 
 
 
 
 
 
 
da73095
 
 
 
 
fd6236c
da73095
 
 
fd6236c
da73095
 
 
88c9820
fd6236c
da73095
fd6236c
 
88c9820
fd6236c
 
 
 
 
 
 
da73095
fd6236c
da73095
 
 
 
 
 
 
 
 
fd6236c
da73095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e91ba59
ea9a277
e91ba59
 
a85915f
 
 
e91ba59
 
 
 
 
ea9a277
e91ba59
 
 
 
 
8149dc4
 
 
da73095
 
ea9a277
da73095
e39cdca
da73095
8149dc4
 
 
0ca0927
8149dc4
 
 
 
 
 
 
0ca0927
5e55543
 
8149dc4
 
 
 
e39cdca
8149dc4
da73095
0ca0927
88c9820
da73095
 
fd6236c
da73095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd6236c
76578a8
da73095
76578a8
da73095
 
 
 
 
 
 
fd6236c
88c9820
 
fd6236c
 
 
da73095
 
fd6236c
da73095
 
 
fd6236c
 
da73095
88c9820
da73095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd6236c
88c9820
 
 
 
fd6236c
 
 
da73095
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5f118a
f7df3ac
fb691f0
78a0ab9
00f1e0c
 
 
 
fb691f0
 
00f1e0c
fb691f0
 
 
 
 
 
 
 
 
 
 
 
bf12c17
fb691f0
 
c5f118a
fb691f0
 
 
 
 
 
 
22919c2
00f1e0c
22919c2
 
 
 
 
 
 
 
 
 
 
fb691f0
 
da73095
fb691f0
88c9820
acceb2a
da73095
 
 
 
 
88c9820
da73095
 
 
 
 
 
 
 
 
 
 
 
 
 
fd6236c
da73095
 
 
 
 
 
fd6236c
da73095
 
88c9820
da73095
88c9820
 
 
da73095
88c9820
da73095
 
 
 
fd6236c
da73095
88c9820
 
8149dc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0ff5c
e303dba
76578a8
5d0ff5c
 
3620b6d
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0ff5c
 
3620b6d
 
 
5d0ff5c
 
d8a6b5e
f8a1dbd
5d0ff5c
 
3620b6d
5d0ff5c
 
8dabad2
5d0ff5c
 
3620b6d
 
5d0ff5c
 
8dabad2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3f9a52
8dabad2
5d0ff5c
3620b6d
 
 
5d0ff5c
3620b6d
5d0ff5c
 
3620b6d
 
 
5d0ff5c
3620b6d
 
5d0ff5c
 
3620b6d
 
 
 
5d0ff5c
 
3620b6d
5d0ff5c
3620b6d
8149dc4
 
 
0fdadfb
 
 
 
03c4a10
8149dc4
 
 
03c4a10
8149dc4
 
 
 
 
 
 
 
03c4a10
8149dc4
 
 
 
 
 
211f12c
 
 
 
 
 
 
8149dc4
d78e376
aaae23b
 
f7df3ac
aaae23b
 
8149dc4
 
 
03c4a10
aaae23b
fe34f00
03c4a10
 
8149dc4
03c4a10
 
 
0fdadfb
 
 
 
 
 
 
03c4a10
8149dc4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c4a10
 
 
 
 
 
8149dc4
 
 
 
 
 
e39cdca
0ca0927
8149dc4
 
 
 
03c4a10
 
8149dc4
 
ea9a277
bc0deaa
8149dc4
 
 
d78e376
2d868e0
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
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
import gradio as gr
import os
import json
import pandas as pd
import random
import shutil
import time
import collections
from functools import wraps
from filelock import FileLock
from datasets import load_dataset, Audio
from huggingface_hub import HfApi, hf_hub_download
from multiprocessing import TimeoutError
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError

# Load dataset from HuggingFace
dataset = load_dataset("intersteller2887/Turing-test-dataset-en", split="train")
dataset = dataset.cast_column("audio", Audio(decode=False)) # Prevent calling 'torchcodec' from newer version of 'datasets'

# Huggingface space working directory: "/home/user/app"
target_audio_dir = "/home/user/app/audio"
os.makedirs(target_audio_dir, exist_ok=True)
COUNT_JSON_PATH = "/home/user/app/count.json"
COUNT_JSON_REPO_PATH = "submissions/count.json" # Output directory (Huggingface dataset directory)

# Copy recordings to the working directory
local_audio_paths = []

for item in dataset:
    src_path = item["audio"]["path"]
    if src_path and os.path.exists(src_path):
        filename = os.path.basename(src_path)
        dst_path = os.path.join(target_audio_dir, filename)
        if not os.path.exists(dst_path):
            shutil.copy(src_path, dst_path)
        local_audio_paths.append(dst_path)

all_data_audio_paths = local_audio_paths

# Take first file of the datasets as sample
sample1_audio_path = local_audio_paths[0]
print(sample1_audio_path)

# ==============================================================================
# Data Definition
# ==============================================================================

DIMENSIONS_DATA = [
    {
        "title": "Semantic and Pragmatic Features",
        "audio": "sample1_audio_path",
        "sub_dims": [
            "Memory Consistency: Human memory in short contexts is usually consistent and self-correcting (e.g., by asking questions); machines may show inconsistent context memory and fail to notice or correct errors (e.g., forgetting key information and persisting in wrong answers).",
            "Logical Coherence: Human logic is naturally coherent and allows reasonable leaps; machine logic is abrupt or self-contradictory (e.g., sudden topic shifts without transitions).",
            "Pronunciation Accuracy: Human-like: Correct and natural pronunciation of words, including context-appropriate usage of common English heteronyms; Machine-like: Unnatural pronunciation errors, especially mispronunciation of common heteronyms",
            "Code-switching: Humans mix multiple languages fluently and contextually; machines mix languages rigidly, lacking logical language switching.",
            "Linguistic Vagueness: Human speech tends to include vague expressions (e.g., “more or less,” “I guess”) and self-corrections; machine responses are typically precise and assertive.",
            "Filler Word Usage: Human filler words (e.g., 'uh', 'like') appear randomly and show signs of thinking; machine fillers are either repetitive and patterned or completely absent.",
            "Metaphor and Pragmatic Intent: Humans use metaphors, irony, and euphemisms to express layered meanings; machines interpret literally or use rhetorical devices awkwardly, lacking semantic richness."
        ],
        "reference_scores": [5, 5, 3, 3, 5, 5, 3]
    },
    {
        "title": "Non-Physiological Paralinguistic Features",
        "audio": "sample1_audio_path",
        "sub_dims": [
            "Rhythm: Human speech rate varies with meaning, occasionally hesitating or pausing; machine rhythm is uniform, with little or mechanical pauses.",
            "Intonation: Humans naturally raise or lower pitch to express questions, surprise, or emphasis; machine intonation is monotonous or overly patterned, mismatching the context.",
            "Emphasis: Humans consciously stress key words to highlight important information; machines have uniform word emphasis or stress incorrect parts.",
            "Auxiliary Vocalizations: Humans produce context-appropriate non-verbal sounds (e.g., laughter, sighs); machine non-verbal sounds are contextually incorrect, mechanical, or absent."
        ],
        "reference_scores": [4, 5, 4, 3]
    },
    {
        "title": "Physiological Paralinguistic Features",
        "audio": "sample1_audio_path",
        "sub_dims": [
            "Micro-physiological Noise: Human speech includes unconscious physiological sounds like breathing, saliva, or bubbling, naturally woven into rhythm; machine speech is overly clean or adds unnatural noises.",
            "Pronunciation Instability: Human pronunciation includes irregularities (e.g., linking, tremors, slurring, nasal sounds); machine pronunciation is overly standard and uniform, lacking personality.",
            "Accent: Humans naturally exhibit regional accents or speech traits; machine accents sound forced or unnatural."
        ],
        "reference_scores": [3, 3, 4]
    },
    {
        "title": "Mechanical Persona",
        "audio": "sample1_audio_path",
        "sub_dims": [
            "Sycophancy: Humans assess context to agree or disagree, sometimes offering differing opinions; machines excessively agree, thank, or apologize, over-validating the other party and lacking authentic interaction.",
            "Formal Expression: Human speech is flexible; machine responses are formally structured, overly written, and use vague wording."
        ],
        "reference_scores": [5, 5]
    },
    {
        "title": "Emotional Expression",
        "audio": "sample1_audio_path",
        "sub_dims": [
            "Semantic Level: Humans show appropriate emotional responses to contexts like sadness or joy; machines are emotionally flat, or use emotional words vaguely and out of context.",
            "Acoustic Level: Human pitch, volume, and rhythm change dynamically with emotion; machine emotional tone is formulaic or mismatched with the context."
        ],
        "reference_scores": [3, 3]
    }
]

DIMENSION_TITLES = [d["title"] for d in DIMENSIONS_DATA]
SPECIAL_KEYWORDS = ["Code-switching", "Metaphor and Pragmatic Intent", "Auxiliary Vocalizations", "Accent"]
MAX_SUB_DIMS = max(len(d['sub_dims']) for d in DIMENSIONS_DATA)
THE_SUB_DIMS = [d['sub_dims'] for d in DIMENSIONS_DATA]



# ==============================================================================
# Backend Function Definitions
# ==============================================================================

# This version did not place file reading into filelock, concurrent read could happen
"""def load_or_initialize_count_json(audio_paths):
    try:
        # Only try downloading if file doesn't exist yet
        if not os.path.exists(COUNT_JSON_PATH):
            downloaded_path = hf_hub_download(
                repo_id="intersteller2887/Turing-test-dataset",
                repo_type="dataset",
                filename=COUNT_JSON_REPO_PATH,
                token=os.getenv("HF_TOKEN")
            )
            # Save it as COUNT_JSON_PATH so that the lock logic remains untouched
            with open(downloaded_path, "rb") as src, open(COUNT_JSON_PATH, "wb") as dst:
                dst.write(src.read())
    except Exception as e:
        print(f"Could not download count.json from HuggingFace dataset: {e}")

    # Add filelock to /workspace/count.json
    lock_path = COUNT_JSON_PATH + ".lock"
    
    # Read of count.json will wait for 10 seconds until another thread involving releases it, and then add a lock to it
    with FileLock(lock_path, timeout=10):
        # If count.json exists: load into count_data
        # Else initialize count_data with orderedDict
        
        if os.path.exists(COUNT_JSON_PATH):
            with open(COUNT_JSON_PATH, "r", encoding="utf-8") as f:
                count_data = json.load(f, object_pairs_hook=collections.OrderedDict)
        else:
            count_data = collections.OrderedDict()

        updated = False
        sample_audio_files = {os.path.basename(d["audio"]) for d in DIMENSIONS_DATA}

        # Guarantee that the sample recording won't be take into the pool
        # Update newly updated recordings into count.json
        for path in audio_paths:
            filename = os.path.basename(path)
            if filename not in count_data:
                if filename in sample_audio_files:
                    count_data[filename] = 999
                else:
                    count_data[filename] = 0
                updated = True

        if updated or not os.path.exists(COUNT_JSON_PATH):
            with open(COUNT_JSON_PATH, "w", encoding="utf-8") as f:
                json.dump(count_data, f, indent=4, ensure_ascii=False)

    return count_data"""

# Function that load or initialize count.json
# Function is called when user start a challenge, and this will load or initialize count.json to working directory
# Initialize happens when count.json does not exist in the working directory as well as HuggingFace dataset
# Load happens when count.json exists in HuggingFace dataset, and it's not loaded to the working directory yet
# After load/initialize, all newly added audio files will be added to count.json with initial value of 0
# Load/Initialize will generate count.json in the working directory for all users under this space

# This version also places file reading into filelock, and modified 
def load_or_initialize_count_json(audio_paths):
    # Add filelock to /workspace/count.json
    lock_path = COUNT_JSON_PATH + ".lock"
    with FileLock(lock_path, timeout=10):
        # If count.json does not exist in the working directory, try to download it from HuggingFace dataset
        if not os.path.exists(COUNT_JSON_PATH):
            try:
                # Save latest count.json to working directory
                downloaded_path = hf_hub_download(
                    repo_id="intersteller2887/Turing-test-dataset-en",
                    repo_type="dataset",
                    filename=COUNT_JSON_REPO_PATH,
                    token=os.getenv("HF_TOKEN")
                )
                with open(downloaded_path, "rb") as src, open(COUNT_JSON_PATH, "wb") as dst:
                    dst.write(src.read())
            except Exception:
                pass

        # If count.json exists in the working directory: load into count_data for potential update
        if os.path.exists(COUNT_JSON_PATH):
            with open(COUNT_JSON_PATH, "r", encoding="utf-8") as f:
                count_data = json.load(f, object_pairs_hook=collections.OrderedDict)
        # Else initialize count_data with orderedDict
        # This happens when there is no count.json (both working directory and HuggingFace dataset)
        else:
            count_data = collections.OrderedDict()

        updated = False
        sample_audio_files = {os.path.basename(d["audio"]) for d in DIMENSIONS_DATA}

        # Guarantee that the sample recording won't be take into the pool
        # Update newly updated recordings into count.json
        for path in audio_paths:
            filename = os.path.basename(path)
            if filename not in count_data:
                if filename in sample_audio_files:
                    count_data[filename] = 999
                else:
                    count_data[filename] = 0
                updated = True

        # Write updated count_data to /home/user/app/count.json
        if updated or not os.path.exists(COUNT_JSON_PATH):
            with open(COUNT_JSON_PATH, "w", encoding="utf-8") as f:
                json.dump(count_data, f, indent=4, ensure_ascii=False)

    return

# Shorten the time of playing previous audio when reached next question
def append_cache_buster(audio_path):
    return f"{audio_path}?t={int(time.time() * 1000)}"

# Function that samples questions from avaliable question set

# This version utilizes a given count_data to sample audio paths
"""def sample_audio_paths(audio_paths, count_data, k=5, max_count=1): # k for questions per test; max_count for question limit in total
    eligible_paths = [p for p in audio_paths if count_data.get(os.path.basename(p), 0) < max_count]

    if len(eligible_paths) < k:
        raise ValueError(f"可用音频数量不足(只剩 {len(eligible_paths)} 条 count<{max_count} 的音频),无法抽取 {k} 条")

    # Shuffule to avoid fixed selections resulted from directory structure
    selected = random.sample(eligible_paths, k)

    # Once sampled a test, update these questions immediately
    for path in selected:
        filename = os.path.basename(path)
        count_data[filename] = count_data.get(filename, 0) + 1

    # Add filelock to /workspace/count.json
    lock_path = COUNT_JSON_PATH + ".lock"
    with FileLock(lock_path, timeout=10):
        with open(COUNT_JSON_PATH, "w", encoding="utf-8") as f:
            json.dump(count_data, f, indent=4, ensure_ascii=False)

    return selected, count_data"""

# This version places file reading into filelock to guarantee correct update of count.json
def sample_audio_paths(audio_paths, k=5, max_count=1):
    # Add filelock to /workspace/count.json
    lock_path = COUNT_JSON_PATH + ".lock"

    # Load newest count.json
    with FileLock(lock_path, timeout=10):
        with open(COUNT_JSON_PATH, "r", encoding="utf-8") as f:
            count_data = json.load(f)
        
        eligible_paths = [
            p for p in audio_paths
            if count_data.get(os.path.basename(p), 0) < max_count
        ]

        if len(eligible_paths) < k:
            raise ValueError(f"可用音频数量不足(只剩 {len(eligible_paths)} 条 count<{max_count} 的音频),无法抽取 {k} 条")

        selected = random.sample(eligible_paths, k)

        # Update count_data
        for path in selected:
            filename = os.path.basename(path)
            count_data[filename] = count_data.get(filename, 0) + 1

        # Update count.json
        with open(COUNT_JSON_PATH, "w", encoding="utf-8") as f:
            json.dump(count_data, f, indent=4, ensure_ascii=False)

    # return selected, count_data
    # Keep count_data atomic
    
    return selected

# ==============================================================================
# Frontend Function Definitions
# ==============================================================================

# Save question_set in each user_data_state, preventing global sharing
def start_challenge(user_data_state):

    load_or_initialize_count_json(all_data_audio_paths)
    # selected_audio_paths, updated_count_data = sample_audio_paths(all_data_audio_paths, k=5)
    # Keep count_data atomic
    selected_audio_paths = sample_audio_paths(all_data_audio_paths, k=5)

    question_set = [
        {"audio": path, "desc": f"这是音频文件 {os.path.basename(path)} 的描述"} 
        for path in selected_audio_paths
    ]

    user_data_state["question_set"] = question_set
    
    # count_data is not needed in the user data
    # user_data_state["updated_count_data"] = updated_count_data
    
    return gr.update(visible=False), gr.update(visible=True), user_data_state

# This function toggles the visibility of the "其他(请注明)" input field based on the selected education choice
def toggle_education_other(choice):
    is_other = (choice == "其他(请注明)")
    return gr.update(visible=is_other, interactive=is_other, value="")

# This function checks if the user information is complete
def check_info_complete(username, age, gender, education, education_other, ai_experience):
    if username.strip() and age and gender and education and ai_experience:
        if education == "其他(请注明)" and not education_other.strip():
            return gr.update(interactive=False)
        return gr.update(interactive=True)
    return gr.update(interactive=False)

# This function updates user_data and initializes the sample page (called when user submits their info)
def show_sample_page_and_init(username, age, gender, education, education_other, ai_experience, user_data):
    final_edu = education_other if education == "其他(请注明)" else education
    user_data.update({
        "username": username.strip(),
        "age": age, 
        "gender": gender, 
        "education": final_edu,
        "ai_experience": ai_experience
    })
    first_dim_title = DIMENSION_TITLES[0]
    
    initial_updates = update_sample_view(first_dim_title)
    
    return [
        gr.update(visible=False), gr.update(visible=True), user_data, first_dim_title
    ] + initial_updates

def update_sample_view(dimension_title):
    dim_data = next((d for d in DIMENSIONS_DATA if d["title"] == dimension_title), None)
    if dim_data:
        audio_up = gr.update(value=dim_data["audio"])
        # audio_up = gr.update(value=append_cache_buster(dim_data["audio"]))
        interactive_view_up = gr.update(visible=True)
        reference_view_up = gr.update(visible=False)
        reference_btn_up = gr.update(value="Reference")
        sample_slider_ups = []
        ref_slider_ups = []
        scores = dim_data.get("reference_scores", [])

        for i in range(MAX_SUB_DIMS):
            if i < len(dim_data['sub_dims']):
                label = dim_data['sub_dims'][i]
                score = scores[i] if i < len(scores) else 0
                sample_slider_ups.append(gr.update(visible=True, label=label, value=0))
                ref_slider_ups.append(gr.update(visible=True, label=label, value=score))
            else:
                sample_slider_ups.append(gr.update(visible=False, value=0))
                ref_slider_ups.append(gr.update(visible=False, value=0))
        return [audio_up, interactive_view_up, reference_view_up, reference_btn_up] + sample_slider_ups + ref_slider_ups
    empty_updates = [gr.update()] * 4
    slider_empty_updates = [gr.update()] * (MAX_SUB_DIMS * 2)
    return empty_updates + slider_empty_updates

def update_test_dimension_view(d_idx, selections):
    # dimension = DIMENSIONS_DATA[d_idx]
    slider_updates = []
    dim_data = DIMENSIONS_DATA[d_idx]
    sub_dims = dim_data["sub_dims"]
    dim_title = dim_data["title"]
    existing_scores = selections.get(dim_data['title'], {})
    progress_d = f"Dimension {d_idx + 1} / {len(DIMENSIONS_DATA)}: **{dim_data['title']}**"

    for i in range(MAX_SUB_DIMS):
        if i < len(sub_dims):
            desc = sub_dims[i]
            # print(f"{desc} -> default value: {existing_scores.get(desc, 0)}")
            name = desc.split(":")[0].strip()
            default_value = 0 if name in SPECIAL_KEYWORDS else 1
            value = existing_scores.get(desc, default_value)

            slider_updates.append(gr.update(
                visible=True,
                label=desc,
                minimum=default_value,
                maximum=5,
                step=1,
                value=value,
                interactive=True,
            ))
            # slider_updates.append(gr.update(
            #     visible=True,
            #     label=desc,
            #     minimum=0 if name in SPECIAL_KEYWORDS else 1,
            #     maximum=5,
            #     value = existing_scores.get(desc, 0),
            #     interactive=True,
            # ))
        else:
            slider_updates.append(gr.update(visible=False))
        # print(f"{desc} -> default value: {existing_scores.get(desc, 0)}")
    # for i in range(MAX_SUB_DIMS):
    #     if i < len(dimension['sub_dims']):
    #         sub_dim_label = dimension['sub_dims'][i]
    #         value = existing_scores.get(sub_dim_label, 0)
    #         slider_updates.append(gr.update(visible=True, label=sub_dim_label, value=value))
    #     else:
    #         slider_updates.append(gr.update(visible=False, value=0))
            
    prev_btn_update = gr.update(interactive=(d_idx > 0))
    next_btn_update = gr.update(
        value="Proceed to Final Judgement" if d_idx == len(DIMENSIONS_DATA) - 1 else "Next Dimension",
        interactive=True
    )
    
    return [gr.update(value=progress_d), prev_btn_update, next_btn_update] + slider_updates

def init_test_question(user_data, q_idx):
    d_idx = 0
    question = user_data["question_set"][q_idx]
    progress_q = f"Question {q_idx + 1} / {len(user_data['question_set'])}"
    
    initial_updates = update_test_dimension_view(d_idx, {})
    dim_title_update, prev_btn_update, next_btn_update = initial_updates[:3]
    slider_updates = initial_updates[3:]
    
    return (
        gr.update(visible=False),
        gr.update(visible=True),
        gr.update(visible=False),
        gr.update(visible=False),
        q_idx, d_idx, {},
        gr.update(value=progress_q),
        dim_title_update,
        gr.update(value=question['audio']),
        # gr.update(value=append_cache_buster(question['audio'])),
        prev_btn_update,
        next_btn_update,
        gr.update(value=None), # BUG FIX: Changed from "" to None to correctly clear the radio button
        gr.update(interactive=False),
    ) + tuple(slider_updates)

def navigate_dimensions(direction, q_idx, d_idx, selections, *slider_values):
    current_dim_data = DIMENSIONS_DATA[d_idx]
    current_sub_dims = current_dim_data['sub_dims']
    scores = {sub_dim: slider_values[i] for i, sub_dim in enumerate(current_sub_dims)}
    selections[current_dim_data['title']] = scores

    new_d_idx = d_idx + (1 if direction == "next" else -1)

    if direction == "next" and d_idx == len(DIMENSIONS_DATA) - 1:
        return (
            gr.update(visible=False),
            gr.update(visible=True),
            q_idx, new_d_idx, selections,
            gr.update(),
            gr.update(),
            gr.update(),
            gr.update(interactive=True),
            gr.update(interactive=False),
            gr.update(interactive=False),
            gr.update(interactive=False),
        ) + (gr.update(),) * MAX_SUB_DIMS

    else:
        view_updates = update_test_dimension_view(new_d_idx, selections)
        dim_title_update, prev_btn_update, next_btn_update = view_updates[:3]
        slider_updates = view_updates[3:]

        return (
            gr.update(), gr.update(),
            q_idx, new_d_idx, selections,
            gr.update(),
            dim_title_update,
            gr.update(),
            gr.update(),
            gr.update(),
            prev_btn_update,
            next_btn_update,
        ) + tuple(slider_updates)

def toggle_reference_view(current):
    if current == "Reference":
        return gr.update(visible=False), gr.update(visible=True), gr.update(value="Back")
    else:
        return gr.update(visible=True), gr.update(visible=False), gr.update(value="Reference")

def back_to_welcome():
    return (
        gr.update(visible=True),   # welcome_page
        gr.update(visible=False),  # info_page
        gr.update(visible=False),  # sample_page
        gr.update(visible=False),  # pretest_page
        gr.update(visible=False),  # test_page
        gr.update(visible=False),  # final_judgment_page
        gr.update(visible=False),  # result_page
        {},                        # user_data_state
        0,                         # current_question_index
        0,                         # current_test_dimension_index
        {},                        # current_question_selections
        []                         # test_results
    )

# ==============================================================================
# Retry Function Definitions
# ==============================================================================

# Decorator function that allows to use ThreadPoolExecutor to retry a function with timeout
def retry_with_timeout(max_retries=3, timeout=10, backoff=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_retries):
                try:
                    with ThreadPoolExecutor(max_workers=1) as executor:
                        future = executor.submit(func, *args, **kwargs)
                        try:
                            result = future.result(timeout=timeout)
                            return result
                        except FutureTimeoutError:
                            future.cancel()
                            raise TimeoutError(f"Operation timed out after {timeout} seconds")
                except Exception as e:
                    last_exception = e
                    print(f"Attempt {attempt + 1} failed: {str(e)}")
                    if attempt < max_retries - 1:
                        time.sleep(backoff * (attempt + 1))
            
            print(f"All {max_retries} attempts failed")
            if last_exception:
                raise last_exception
            raise Exception("Unknown error occurred")
        return wrapper
    return decorator

def save_with_retry(all_results, user_data):
    # 尝试上传到Hugging Face Hub
    try:
        # 使用线程安全的保存方式
        with ThreadPoolExecutor(max_workers=1) as executor:
            future = executor.submit(save_all_results_to_file, all_results, user_data)
            try:
                future.result(timeout=30)  # 设置30秒超时
                return True
            except FutureTimeoutError:
                future.cancel()
                print("上传超时")
                return False
    except Exception as e:
        print(f"上传到Hub失败: {e}")
        return False

def save_locally_with_retry(data, filename, max_retries=3):
    for attempt in range(max_retries):
        try:
            with open(filename, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=4, ensure_ascii=False)
            return True
        except Exception as e:
            print(f"本地保存尝试 {attempt + 1} 失败: {e}")
            if attempt < max_retries - 1:
                time.sleep(1)
    return False

def update_count_with_retry(count_data, question_set, max_retries=3):
    for attempt in range(max_retries):
        try:
            lock_path = COUNT_JSON_PATH + ".lock"
            with FileLock(lock_path, timeout=10):
                # Remove unfinished question(s) from count.json
                for question in question_set:
                    filename = os.path.basename(question['audio'])
                    if filename in count_data and count_data[filename] < 1:
                        count_data[filename] = 0 # Mark unfinished data as 0

                with open(COUNT_JSON_PATH, 'w', encoding='utf-8') as f:
                    json.dump(count_data, f, indent=4, ensure_ascii=False)
            return True
        except Exception as e:
            print(f"Fail to update count.json {e} for {attempt + 1} time")
            if attempt < max_retries - 1:
                time.sleep(1)
    return False

# ==============================================================================

# Previous version of submit_question_and_advance
"""def submit_question_and_advance(q_idx, d_idx, selections, final_choice, all_results, user_data):
    # selections["final_choice"] = final_choice

    cleaned_selections = {}
    for dim_title, sub_scores in selections.items():
        # if dim_title == "final_choice": # 去掉if判断
        cleaned_selections["final_choice"] = final_choice
            # continue
        cleaned_sub_scores = {}
        for sub_dim, score in sub_scores.items():
            cleaned_sub_scores[sub_dim] = None if score == 0 else score
        cleaned_selections[dim_title] = cleaned_sub_scores

    final_question_result = {
        "question_id": q_idx,
        "audio_file": user_data["question_set"][q_idx]['audio'],
        "selections": cleaned_selections
    }

    all_results.append(final_question_result)
    
    q_idx += 1

    # If q_idx hasn't reached the last one
    if q_idx < len(user_data["question_set"]):
        init_q_updates = init_test_question(user_data, q_idx) # Case 1: jam happens when initialize next question
        return init_q_updates + (all_results, gr.update(value=""))
    # If q_idx has reached the last one
    else:
        result_str = "### 测试全部完成!\n\n你的提交结果概览:\n"
        for res in all_results:
            # result_str += f"\n#### 题目: {res['audio_file']}\n"
            result_str += f"##### 最终判断: **{res['selections'].get('final_choice', '未选择')}**\n"
            for dim_title, dim_data in res['selections'].items():
                if dim_title == 'final_choice': continue
                result_str += f"- **{dim_title}**:\n"
                for sub_dim, score in dim_data.items():
                    result_str += f"  - *{sub_dim[:20]}...*: {score}/5\n"
        
        # save_all_results_to_file(all_results, user_data)
        # save_all_results_to_file(all_results, user_data, count_data=updated_count_data)
        save_all_results_to_file(all_results, user_data, count_data=user_data.get("updated_count_data"))
        
        return (
            gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True),
            q_idx, d_idx, {},
            gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
            gr.update(), gr.update(),
        ) + (gr.update(),) * MAX_SUB_DIMS + (all_results, result_str)"""

# user_data now no further contain "updated_count_data", which should be read/write with filelock and be directly accessed from working directory
def submit_question_and_advance(q_idx, d_idx, selections, final_choice, all_results, user_data):
    try:
        # 准备数据
        cleaned_selections = {}
        for dim_title, sub_scores in selections.items():
            cleaned_selections["final_choice"] = final_choice
            cleaned_sub_scores = {}
            for sub_dim, score in sub_scores.items():
                cleaned_sub_scores[sub_dim] = None if score == 0 else score
            cleaned_selections[dim_title] = cleaned_sub_scores

        final_question_result = {
            "question_id": q_idx,
            "audio_file": user_data["question_set"][q_idx]['audio'],
            "selections": cleaned_selections
        }
        
        all_results.append(final_question_result)
        q_idx += 1

        if q_idx < len(user_data["question_set"]):
            init_q_updates = init_test_question(user_data, q_idx)
            return init_q_updates + (all_results, gr.update(value=""))
        else:
            # 准备完整结果数据
            result_str = "### Test Completed!\n\nOverview of your submission:\n"
            for res in all_results:
                result_str += f"##### Final Judgement: **{res['selections'].get('final_choice', 'empty')}**\n" # empty == no choice
                for dim_title, dim_data in res['selections'].items():
                    if dim_title == 'final_choice': continue
                    result_str += f"- **{dim_title}**:\n"
                    for sub_dim, score in dim_data.items():
                        result_str += f"  - *{sub_dim[:20]}...*: {score}/5\n"
            
            # 尝试上传(带重试)
            try:
                # success = save_with_retry(all_results, user_data, user_data.get("updated_count_data"))
                success = save_with_retry(all_results, user_data)
            except Exception as e:
                print(f"上传过程中发生错误: {e}")
                success = False
            
            if not success:
                # 上传失败,保存到本地
                username = user_data.get("username", "anonymous")
                timestamp = pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')
                local_filename = f"submission_{username}_{timestamp}.json"
                
                # 准备数据包
                user_info_clean = {
                    k: v for k, v in user_data.items() if k not in ["question_set"]
                }
                final_data_package = {
                    "user_info": user_info_clean,
                    "results": all_results
                }
                
                # 尝试保存到本地
                local_success = save_locally_with_retry(final_data_package, local_filename)
                
                if local_success:
                    result_str += f"\n\n⚠️ 上传失败,结果已保存到本地文件: {local_filename}"
                else:
                    result_str += "\n\n❌ 上传失败且无法保存到本地文件,请联系管理员"
                
                # 更新count.json(剔除未完成的题目)
                try:
                    with FileLock(COUNT_JSON_PATH + ".lock", timeout=5):
                        with open(COUNT_JSON_PATH, "r", encoding="utf-8") as f:
                            count_data = json.load(f, object_pairs_hook=collections.OrderedDict)
                    count_update_success = update_count_with_retry(count_data, user_data["question_set"])
                except Exception as e:
                    print(f"更新count.json失败: {e}")
                    count_update_success = False
                
                if not count_update_success:
                    result_str += "\n\n⚠️ 无法更新题目计数,请联系管理员"
            
            return (
                gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=True),
                q_idx, d_idx, {},
                gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
                gr.update(), gr.update(),
            ) + (gr.update(),) * MAX_SUB_DIMS + (all_results, result_str)
    except Exception as e:
        print(f"提交过程中发生错误: {e}")
        # 返回错误信息
        error_msg = f"提交过程中发生错误: {str(e)}"
        return (
            gr.update(), gr.update(), gr.update(), gr.update(),
            q_idx, d_idx, selections,
            gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
            gr.update(), gr.update(),
        ) + (gr.update(),) * MAX_SUB_DIMS + (all_results, error_msg)

"""def save_all_results_to_file(all_results, user_data, count_data=None):
    repo_id = "intersteller2887/Turing-test-dataset"
    username = user_data.get("username", "user")
    timestamp = pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')
    submission_filename = f"submissions_{username}_{timestamp}.json"

    user_info_clean = {
        k: v for k, v in user_data.items() if k not in ["question_set", "updated_count_data"]
    }
    
    final_data_package = {
        "user_info": user_info_clean,
        "results": all_results
    }
    json_string = json.dumps(final_data_package, ensure_ascii=False, indent=4)
    hf_token = os.getenv("HF_TOKEN")

    if not hf_token:
        print("HF_TOKEN not found. Cannot upload to the Hub.")
        return

    try:
        api = HfApi()

        # Upload submission file
        api.upload_file(
            path_or_fileobj=bytes(json_string, "utf-8"),
            path_in_repo=f"submissions/{submission_filename}",
            repo_id=repo_id,
            repo_type="dataset",
            token=hf_token,
            commit_message=f"Add new submission from {username}"
        )
        print(f"上传成功: {submission_filename}")

        if count_data:
            with FileLock(COUNT_JSON_PATH + ".lock", timeout=10):
                with open(COUNT_JSON_PATH, "w", encoding="utf-8") as f:
                    json.dump(count_data, f, indent=4, ensure_ascii=False)
    
            api.upload_file(
                path_or_fileobj=COUNT_JSON_PATH,
                path_in_repo=COUNT_JSON_REPO_PATH,
                repo_id=repo_id,
                repo_type="dataset",
                token=hf_token,
                commit_message=f"Update count.json after submission by {username}"
            )

    except Exception as e:
        print(f"上传出错: {e}")"""

def save_all_results_to_file(all_results, user_data):
    repo_id = "intersteller2887/Turing-test-dataset-en"
    username = user_data.get("username", "user")
    timestamp = pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')
    submission_filename = f"submissions_{username}_{timestamp}.json"

    user_info_clean = {
        k: v for k, v in user_data.items() if k not in ["question_set"]
    }
    
    final_data_package = {
        "user_info": user_info_clean,
        "results": all_results
    }
    json_string = json.dumps(final_data_package, ensure_ascii=False, indent=4)
    hf_token = os.getenv("HF_TOKEN")

    if not hf_token:
        raise Exception("HF_TOKEN not found. Cannot upload to the Hub.")

    api = HfApi()

    # 上传提交文件(不再使用装饰器,直接调用)
    api.upload_file(
        path_or_fileobj=bytes(json_string, "utf-8"),
        path_in_repo=f"submissions/{submission_filename}",
        repo_id=repo_id,
        repo_type="dataset",
        token=hf_token,
        commit_message=f"Add new submission from {username}"
    )

    try:
        with FileLock(COUNT_JSON_PATH + ".lock", timeout=5):
            with open(COUNT_JSON_PATH, "r", encoding="utf-8") as f:
                count_data_str = f.read()
        
        api.upload_file(
            path_or_fileobj=bytes(count_data_str, "utf-8"),
            path_in_repo=COUNT_JSON_REPO_PATH,
            repo_id=repo_id,
            repo_type="dataset",
            token=hf_token,
            commit_message=f"Update count.json after submission by {username}"
        )
    except Exception as e:
        print(f"上传 count.json 失败: {e}")

# ==============================================================================
# Gradio 界面定义 (Gradio UI Definition)
# ==============================================================================
with gr.Blocks(theme=gr.themes.Soft(), css=".gradio-container {max-width: 960px !important}") as demo:
    user_data_state = gr.State({})
    current_question_index = gr.State(0)
    current_test_dimension_index = gr.State(0)
    current_question_selections = gr.State({})
    test_results = gr.State([])

    welcome_page = gr.Column(visible=True)
    info_page = gr.Column(visible=False)
    sample_page = gr.Column(visible=False)
    pretest_page = gr.Column(visible=False)
    test_page = gr.Column(visible=False)
    final_judgment_page = gr.Column(visible=False)
    result_page = gr.Column(visible=False)
    pages = {
        "welcome": welcome_page, "info": info_page, "sample": sample_page,
        "pretest": pretest_page, "test": test_page, "final_judgment": final_judgment_page,
        "result": result_page
    }

    with welcome_page:
        gr.Markdown("# Can you spot the hidden AI?\nListen to the following conversations. Try to tell which respondent is an AI.")
        start_btn = gr.Button("Start", variant="primary")

    with info_page:
        gr.Markdown("## Basic Information")
        username_input = gr.Textbox(label="Username", placeholder="Please enter your nickname")
        age_input = gr.Radio(["Under 18", "18-25", "26-35", "36-50", "Over 50"], label="Age")
        gender_input = gr.Radio(["Male", "Female", "Other"], label="Gender")
        education_input = gr.Radio(["High school or below", "Bachelor", "Master", "PhD", "Other (please specify)"], label="Education Level")
        education_other_input = gr.Textbox(label="Please enter your education", visible=False, interactive=False)
        ai_experience_input = gr.Radio([
            "Never used",
            "Occasionally exposed (e.g., watching others use)",
            "Used a few times, understand basic functions",
            "Use frequently, have some experience",
            "Very familiar, have in-depth experience with multiple AI tools"
        ], label="Familiarity with AI Tools")
        submit_info_btn = gr.Button("Submit and Start Learning Sample", variant="primary", interactive=False)


    with sample_page:
        gr.Markdown("## Sample Analysis\nPlease select a dimension to study and practice scoring. All dimensions share the same sample audio.")
        sample_dimension_selector = gr.Radio(DIMENSION_TITLES, label="Select Learning Dimension", value=DIMENSION_TITLES[0])
        with gr.Row():
            with gr.Column(scale=1):
                # sample_audio = gr.Audio(label="Sample Audio", value=DIMENSIONS_DATA[0]["audio"])
                sample_audio = gr.Audio(label="Sample Audio", value=sample1_audio_path)
            with gr.Column(scale=2):
                with gr.Column(visible=True) as interactive_view:
                    gr.Markdown("#### Please rate the following features (0-5 points. 0 - Feature not present; 1 - Machine; 3 - Neutral; 5 - Human)")
                    sample_sliders = [gr.Slider(minimum=0, maximum=5, step=1, label=f"Sub-dim {i+1}", visible=False, interactive=True) for i in range(MAX_SUB_DIMS)]
                with gr.Column(visible=False) as reference_view:
                    gr.Markdown("### Reference Answer Explanation (1-5 points. 1 = Machine-like, 5 = Human-like)")
                    reference_sliders = [gr.Slider(minimum=0, maximum=5, step=1, label=f"Sub-dim {i+1}", visible=False, interactive=False) for i in range(MAX_SUB_DIMS)]
        with gr.Row():
            reference_btn = gr.Button("Reference")
            go_to_pretest_btn = gr.Button("Got it, start the test", variant="primary")

    with pretest_page:
        gr.Markdown("""## Pre-Test Instructions

- For each question, you'll evaluate the **response** (not the initiator) across **5 dimensions**.
- Under each dimension, score **every listed feature** from **0 to 5**:

### 🔢 Scoring Guide:
- **0** – The feature is **not present** *(some features are always present, so use 1–5 in those cases)*  
- **1** – Strongly machine-like  
- **2** – Somewhat machine-like  
- **3** – Neutral (no clear human or machine lean)  
- **4** – Somewhat human-like  
- **5** – Strongly human-like  

- After rating all dimensions, make a final judgment: is the **responder** a human or an AI?
- You can freely switch between dimensions using the **Previous** and **Next** buttons.

---

### ⚠️ Important Notes:

- Focus on whether the **responder's speech** sounds more **human-like or machine-like** for each feature — not just whether the feature is "present".
> For example: correct pronunciation doesn't always mean "human", and mispronunciation doesn't mean "AI". Think in terms of human-likeness.
  
- Even if you're confident early on about the responder's identity, still evaluate **each dimension independently**.  
  Avoid just labeling all dimensions as "machine-like" or "human-like" without listening carefully.
""")
        go_to_test_btn = gr.Button("Start the Test", variant="primary")


        
   
        
    with test_page:
        gr.Markdown("## Formal Test")
        question_progress_text = gr.Markdown()
        test_dimension_title = gr.Markdown()
        test_audio = gr.Audio(label="Test Audio")
        gr.Markdown("--- \n ### Please rate the respondent (not the initiator) in the conversation based on the following features (0-5 points. 0 - Feature not present; 1 - Machine; 3 - Neutral; 5 - Human)")
        test_sliders = [gr.Slider(minimum=0, maximum=5, step=1, label=f"Sub-dim {i+1}", visible=False, interactive=True) for i in range(MAX_SUB_DIMS)]
        with gr.Row():
            prev_dim_btn = gr.Button("Previous Dimension")
            next_dim_btn = gr.Button("Next Dimension", variant="primary")

    with final_judgment_page:
        gr.Markdown("## Final Judgment")
        gr.Markdown("You have completed scoring for all dimensions. Please make a final judgment based on your overall impression.")
        final_human_robot_radio = gr.Radio(["👤 Human", "🤖 AI"], label="Please determine the respondent type (required)")
        submit_final_answer_btn = gr.Button("Submit Answer for This Question", variant="primary", interactive=False)

    with result_page:
        gr.Markdown("## Test Completed")
        result_text = gr.Markdown()
        back_to_welcome_btn = gr.Button("Back to Main Page", variant="primary")
    # ==============================================================================
    # 事件绑定 (Event Binding) & IO 列表定义
    # ==============================================================================
    sample_init_outputs = [
        info_page, sample_page, user_data_state, sample_dimension_selector,
        sample_audio, interactive_view, reference_view, reference_btn
    ] + sample_sliders + reference_sliders
    
    test_init_outputs = [
        pretest_page, test_page, final_judgment_page, result_page,
        current_question_index, current_test_dimension_index, current_question_selections,
        question_progress_text, test_dimension_title, test_audio,
        prev_dim_btn, next_dim_btn,
        final_human_robot_radio, submit_final_answer_btn,
    ] + test_sliders

    nav_inputs = [current_question_index, current_test_dimension_index, current_question_selections] + test_sliders
    nav_outputs = [
        test_page, final_judgment_page,
        current_question_index, current_test_dimension_index, current_question_selections,
        question_progress_text, test_dimension_title, test_audio,
        final_human_robot_radio, submit_final_answer_btn,
        prev_dim_btn, next_dim_btn,
    ] + test_sliders
    
    full_outputs_with_results = test_init_outputs + [test_results, result_text]

    # start_btn.click(fn=start_challenge, outputs=[welcome_page, info_page])
    start_btn.click(
        fn=start_challenge, 
        inputs=[user_data_state],
        outputs=[welcome_page, info_page, user_data_state]
    )

    
    for comp in [age_input, gender_input, education_input, education_other_input, ai_experience_input]:
        comp.change(
            fn=check_info_complete,
            inputs=[username_input, age_input, gender_input, education_input, education_other_input, ai_experience_input],
            outputs=submit_info_btn
        )
    
    education_input.change(fn=toggle_education_other, inputs=education_input, outputs=education_other_input)
    
    submit_info_btn.click(
        fn=show_sample_page_and_init,
        inputs=[username_input, age_input, gender_input, education_input, education_other_input, ai_experience_input, user_data_state],
        outputs=sample_init_outputs
    )
    
    sample_dimension_selector.change(
        fn=update_sample_view, 
        inputs=sample_dimension_selector, 
        outputs=[sample_audio, interactive_view, reference_view, reference_btn] + sample_sliders + reference_sliders
    )

    reference_btn.click(
        fn=toggle_reference_view, 
        inputs=reference_btn, 
        outputs=[interactive_view, reference_view, reference_btn]
    )
    
    go_to_pretest_btn.click(lambda: (gr.update(visible=False), gr.update(visible=True)), outputs=[sample_page, pretest_page])
    
    go_to_test_btn.click(
        fn=lambda user: init_test_question(user, 0) + ([], gr.update()),
        inputs=[user_data_state],
        outputs=full_outputs_with_results
    )
    
    prev_dim_btn.click(
        fn=lambda q,d,s, *sliders: navigate_dimensions("prev", q,d,s, *sliders),
        inputs=nav_inputs, outputs=nav_outputs
    )
    
    next_dim_btn.click(
        fn=lambda q,d,s, *sliders: navigate_dimensions("next", q,d,s, *sliders),
        inputs=nav_inputs, outputs=nav_outputs
    )

    final_human_robot_radio.change(
        fn=lambda choice: gr.update(interactive=bool(choice)),
        inputs=final_human_robot_radio,
        outputs=submit_final_answer_btn
    )

    submit_final_answer_btn.click(
        fn=submit_question_and_advance,
        inputs=[current_question_index, current_test_dimension_index, current_question_selections, final_human_robot_radio, test_results, user_data_state],
        outputs=full_outputs_with_results
    )

    back_to_welcome_btn.click(fn=back_to_welcome, outputs=list(pages.values()) + [user_data_state, current_question_index, current_test_dimension_index, current_question_selections, test_results])

# ==============================================================================
# 程序入口 (Entry Point)
# ==============================================================================
if __name__ == "__main__":
    if not os.path.exists("audio"):
        os.makedirs("audio")
    if "SPACE_ID" in os.environ:
        print("Running in a Hugging Face Space, checking for audio files...")
        # all_files = [q["audio"] for q in QUESTION_SET] + [d["audio"] for d in DIMENSIONS_DATA]
        all_files = [d["audio"] for d in DIMENSIONS_DATA]
        for audio_file in set(all_files):
            if not os.path.exists(audio_file):
                print(f"⚠️ Warning: Audio file not found: {audio_file}")
    
    demo.launch(debug=True)