File size: 48,807 Bytes
e1c0b77
 
 
 
b0eeec4
 
 
c572e8a
b5f4a07
c572e8a
 
b5f4a07
c572e8a
 
 
 
978d90d
c572e8a
53f316c
b5f4a07
c572e8a
 
 
 
 
 
e1c0b77
b5f4a07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97e5b2d
9fbc532
97e5b2d
f72a551
 
 
9fbc532
 
 
 
97e5b2d
 
 
 
 
9fbc532
97e5b2d
9fbc532
 
97e5b2d
9fbc532
 
f72a551
d9f3227
9fbc532
f72a551
 
9fbc532
d9f3227
f72a551
 
 
 
 
 
97e5b2d
9fbc532
c80db61
 
 
d9f3227
 
c80db61
 
 
 
 
0ec5648
c80db61
 
 
d9f3227
 
 
0ec5648
d9f3227
 
 
 
 
 
 
 
 
 
 
 
 
9fbc532
867662d
 
 
 
 
 
 
 
 
 
f09fe58
 
 
9fbc532
 
 
 
 
 
 
 
 
97e5b2d
f09fe58
 
 
 
 
 
9fbc532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f09fe58
9fbc532
f09fe58
 
9fbc532
f09fe58
9fbc532
 
f09fe58
9fbc532
f09fe58
9fbc532
f09fe58
 
9fbc532
 
f09fe58
9fbc532
 
 
 
 
 
 
f09fe58
e8993e4
f72a551
 
 
 
93eb128
 
3f931e8
978d90d
 
d6632b3
eb2f556
 
 
e7d55a7
 
 
d9f3227
 
e8993e4
 
 
 
e7d55a7
53f316c
 
 
 
 
 
 
ee14d3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7d55a7
 
 
 
 
 
 
 
97e5b2d
 
 
 
 
7941f4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1c0b77
 
 
 
83ec3f5
 
 
 
 
 
 
 
 
e129f37
83ec3f5
 
7941f4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8993e4
7941f4d
83ec3f5
7941f4d
83ec3f5
 
 
e8993e4
83ec3f5
e8993e4
e129f37
83ec3f5
e8993e4
e129f37
83ec3f5
7941f4d
 
e1c0b77
 
b5f4a07
83ec3f5
2f3700c
7941f4d
e1c0b77
2f3700c
3f931e8
83ec3f5
 
2f3700c
83ec3f5
2f3700c
770006e
 
 
 
 
83ec3f5
2f3700c
12c4c0f
3f931e8
e1c0b77
 
b5f4a07
978d90d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5f4a07
83ec3f5
7941f4d
e1c0b77
7941f4d
83ec3f5
7941f4d
83ec3f5
 
 
 
 
 
 
e129f37
e1c0b77
 
5158491
eb2f556
 
53f316c
 
eb2f556
 
 
53f316c
 
 
 
 
 
e1c0b77
7941f4d
e1c0b77
 
f2b3289
12edc45
 
 
 
 
 
 
 
978d90d
 
 
ee14d3e
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97e5b2d
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb2f556
 
 
12edc45
 
 
57ebbd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee14d3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57ebbd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67da08d
12edc45
 
 
 
3f931e8
 
 
12edc45
 
 
 
ee14d3e
 
 
 
 
 
 
 
 
 
 
eb2f556
 
 
53f316c
 
ee14d3e
eb2f556
 
 
 
ee14d3e
 
 
 
 
 
 
eb2f556
ee14d3e
 
 
 
 
 
 
 
 
 
 
 
 
53f316c
 
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978d90d
 
ee14d3e
978d90d
 
d6632b3
 
 
 
 
 
 
978d90d
 
 
8fd63cd
 
978d90d
 
 
 
 
 
 
 
ff2dbd5
978d90d
 
 
 
 
 
 
 
 
 
 
 
d6632b3
 
92295c9
d6632b3
978d90d
 
ee14d3e
978d90d
 
 
 
 
 
 
 
ff2dbd5
 
978d90d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12edc45
 
 
 
978d90d
80f7ff0
12edc45
 
 
978d90d
12edc45
 
 
 
 
978d90d
 
 
 
 
 
 
 
 
 
 
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
93eb128
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ee14d3e
12edc45
 
 
 
 
 
978d90d
 
 
 
 
d6632b3
 
978d90d
 
12edc45
978d90d
12edc45
 
 
 
 
 
eb2f556
 
12edc45
 
 
 
 
 
 
 
eb2f556
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
57ebbd6
 
ee14d3e
 
97e5b2d
12edc45
 
 
b5f4a07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12edc45
80f7ff0
 
 
 
b5f4a07
 
80f7ff0
 
b5f4a07
 
80f7ff0
 
 
 
b5f4a07
 
 
 
 
 
 
 
 
 
 
 
 
 
12c4c0f
9fbc532
97e5b2d
978d90d
 
 
 
 
b5f4a07
 
978d90d
 
b5f4a07
978d90d
 
b5f4a07
978d90d
 
 
12edc45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53f316c
12edc45
f2b3289
 
 
 
 
 
 
e1c0b77
7941f4d
 
83ec3f5
7941f4d
e1c0b77
d9f3227
f09fe58
 
97e5b2d
e1c0b77
d9f3227
e8993e4
97e5b2d
e1c0b77
d9f3227
e8993e4
12edc45
e1c0b77
93eb128
 
 
53f316c
d6632b3
 
 
 
 
96f9864
eb2f556
 
7941f4d
e1c0b77
 
7941f4d
83ec3f5
e129f37
7941f4d
93eb128
e1c0b77
d6632b3
3f931e8
e1c0b77
7941f4d
93eb128
e1c0b77
d6632b3
83ec3f5
eb2f556
 
 
53f316c
eb2f556
53f316c
e1c0b77
 
978d90d
 
d6632b3
978d90d
 
 
c572e8a
3aa55b2
 
7458c71
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
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
"""
PaperProf β€” Main Gradio entry point.
"""

import warnings
warnings.filterwarnings("ignore")

try:
    import os
    import random
    import pathlib
    import threading
    import gradio as gr
    import spaces
    from core.parser import extract_text
    from core.chunker import chunk_text
    from core.questioner import generate_question, generate_mcq
    from core.evaluator import evaluate_answer
    from core.image_gen import generate_concept_image
    from model.llm import get_llm
    print("βœ… All imports successful")
except Exception as e:
    import traceback
    print(f"❌ Import error: {e}")
    traceback.print_exc()
    raise

RUNTIME = os.environ.get("PAPERPROF_RUNTIME", "transformers").lower()


def gpu_if_needed(duration):
    """@spaces.GPU only for the transformers runtime β€” llama.cpp runs on CPU
    and must not be capped by (or waste) a ZeroGPU window."""
    def wrap(fn):
        return spaces.GPU(duration=duration)(fn) if RUNTIME != "llamacpp" else fn
    return wrap


if RUNTIME == "llamacpp":
    # Load the GGUF into RAM at startup so the first question only pays
    # generation time, not the ~60s model load.
    threading.Thread(target=get_llm, daemon=True).start()

CSS = """
/* ── Base ──────────────────────────────────────────────────────────────────── */
footer { display: none !important; }
/* Only hide Gradio's own header chrome β€” NOT our custom <header class="hero"> */
.gradio-container > header,
.app > header { display: none !important; }
* { box-sizing: border-box; }
body { background: #0A0F1E !important; margin: 0 !important; }

/* ── Gradio shell ───────────────────────────────────────────────────────────── */
.gradio-container {
    padding: 0 !important;
    margin: 0 !important;
    max-width: 100% !important;
    background: #0A0F1E !important;
    min-height: 0 !important;
}
.main  { padding: 0 !important; }
.gap   { gap: 0 !important; }
.contain { padding: 0 !important; }
#component-0 { padding: 0 !important; }

/* ── Upload row: centred column, card look ──────────────────────────────────── */
#upload-row {
    max-width: 760px !important;
    margin: 0 auto !important;
    padding: 20px !important;
    flex-direction: column !important;
    gap: 12px !important;
    background: rgba(10,15,30,0.65) !important;
    border: 1px solid rgba(255,255,255,0.08) !important;
    border-radius: 18px !important;
    backdrop-filter: blur(24px) !important;
    -webkit-backdrop-filter: blur(24px) !important;
    box-shadow: 0 8px 32px rgba(0,0,0,0.45) !important;
}

/* ── Rows are moved off-screen but NOT display:none so Gradio attaches event
   handlers; height:0 + overflow:visible let their textboxes bleed into the
   page as a dark full-width bar, so park them off-viewport instead. ── */
#hidden-row-status,
#hidden-row-question {
    position: fixed !important;
    top: -9999px !important;
    left: -9999px !important;
    width: 1px !important;
    height: 1px !important;
    min-height: 0 !important;
    overflow: hidden !important;
    opacity: 0 !important;
    pointer-events: none !important;
    margin: 0 !important;
    padding: 0 !important;
    border: none !important;
    gap: 0 !important;
}

/* ── Force hero text to be visible (defeat Gradio color resets) ─────────────── */
.hero-title {
    color: #A78BFA !important;
    background: linear-gradient(135deg, #A78BFA 0%, #67E8F9 100%) !important;
    -webkit-background-clip: text !important;
    -webkit-text-fill-color: transparent !important;
    background-clip: text !important;
}
.hero-sub { color: #94A3B8 !important; }
.hero-icon { color: initial !important; font-size: 3.2rem !important; display: block !important; }

/* ── Upload zone card ───────────────────────────────────────────────────────── */
/* Gradio's own block/file wrappers paint a beige fill on dark theme β€” make
   everything inside the upload row transparent so the glass card shows. */
#upload-row .block,
#upload-row .file,
#upload-row .gradio-file,
#upload-row .wrap,
#upload-row [data-testid="block"] {
    background: transparent !important;
    border-color: transparent !important;
}
[data-testid="upload-zone"],
.file-drop-zone,
.upload-container {
    background: rgba(255,255,255,0.03) !important;
    border: 2px dashed rgba(124,58,237,0.4) !important;
    border-radius: 14px !important;
    min-height: 130px !important;
    color: rgba(255,255,255,0.65) !important;
    text-align: center !important;
    transition: border-color .2s, box-shadow .2s, background .2s !important;
    backdrop-filter: blur(12px) !important;
    -webkit-backdrop-filter: blur(12px) !important;
}
[data-testid="upload-zone"]:hover,
.file-drop-zone:hover {
    border-color: #06B6D4 !important;
    background: rgba(6,182,212,0.04) !important;
    box-shadow: 0 0 28px rgba(6,182,212,0.15) !important;
}
[data-testid="upload-zone"] *,
.file-drop-zone * { color: rgba(255,255,255,0.7) !important; }

/* ── File preview after selection ──────────────────────────────────────────── */
.file-preview-holder,
.file-name-wrapper,
.file-preview {
    background: rgba(124,58,237,0.07) !important;
    border: 1px solid rgba(124,58,237,0.2) !important;
    border-radius: 10px !important;
    color: #A78BFA !important;
}
.file-preview * { color: #A78BFA !important; }

/* ── Hide all Gradio component labels ──────────────────────────────────────── */
.block > label,
.block > .form > label,
.label-wrap,
.block label span { display: none !important; }

/* ── Load PDF button β€” full-width gradient pill ─────────────────────────────── */
#hidden-load-btn { width: 100% !important; margin-top: 12px !important; }
#hidden-load-btn button {
    width: 100% !important;
    background: linear-gradient(135deg, #7C3AED, #06B6D4) !important;
    border-radius: 50px !important;
    color: #fff !important;
    font-weight: 700 !important;
    font-size: 1.1rem !important;
    letter-spacing: 0.5px !important;
    border: none !important;
    padding: 16px !important;
    box-shadow: 0 4px 20px rgba(124,58,237,0.35) !important;
    transition: box-shadow .2s, transform .2s !important;
}
#hidden-load-btn button:hover {
    box-shadow: 0 6px 28px rgba(124,58,237,0.55) !important;
    transform: translateY(-1px) !important;
}

/* ── Post-load: upload area recedes ────────────────────────────────────────── */
.upload-receded {
    opacity: 0.45 !important;
    transform: scale(0.98) !important;
    transition: opacity .4s ease, transform .4s ease !important;
    pointer-events: none !important;
}

/* ── Standalone hidden components (no row wrapper) ──────────────────────────── */
#hidden-feedback-output,
#hidden-answer-input,
#hidden-submit-btn,
#hidden-question-btn,
#hidden-question-trigger,
#hidden-answer-trigger,
#hidden-chunk-output,
#hidden-mcq-trigger,
#hidden-mcq-output,
#hidden-language-box,
#hidden-session-topics,
#hidden-generate-session-image-btn {
    position: fixed !important;
    top: -9999px !important;
    left: -9999px !important;
    width: 1px !important;
    height: 1px !important;
    overflow: hidden !important;
    opacity: 0 !important;
    pointer-events: none !important;
}

/* ── Hidden concept image component ─────────────────────────────────────────── */
#hidden-concept-image {
    position: fixed !important;
    top: -9999px !important;
    opacity: 0 !important;
}

/* ── Empty .form wrappers ───────────────────────────────────────────────────────
   Gradio groups consecutive textboxes into bordered .form containers; ours are
   all parked off-screen, leaving 3 empty bars at the page bottom β€” hide them. */
.form:has(> #hidden-answer-input),
.form:has(> #hidden-feedback-output),
.form:has(> #hidden-question-trigger),
.form:has(> #hidden-session-topics) {
    position: fixed !important;
    top: -9999px !important;
    left: -9999px !important;
    width: 1px !important;
    height: 1px !important;
    border: none !important;
}
/* fallback for browsers without :has β€” at least make the bars invisible */
.gradio-container .form {
    background: transparent !important;
    border-color: transparent !important;
    box-shadow: none !important;
}

/* ── Load PDF button β€” defeat Gradio's orange primary override ──────────────── */
.gr-button-primary,
button.primary,
#hidden-load-btn button,
#hidden-load-btn .gr-button {
    background: linear-gradient(135deg, #7C3AED, #06B6D4) !important;
    background-color: #7C3AED !important;
}
"""

import pathlib
CUSTOM_HTML = pathlib.Path("ui/index.html").read_text()

# ---------------------------------------------------------------------------
# Internationalisation
# ---------------------------------------------------------------------------

UI = {
    "English": {
        "load_btn":          "Load PDF",
        "new_q_btn":         "New Question",
        "submit_btn":        "Submit Answer",
        "pdf_label":         "πŸ“Ž Course PDF",
        "status_label":      "Status",
        "question_label":    "❓ Question",
        "answer_label":      "✏️ Your Answer",
        "answer_ph":         "Write your answer here…",
        "feedback_label":    "πŸ’¬ Feedback",
        "no_file":           "No file selected.",
        "no_chunks":         "PDF loaded but no text found (scanned or too short?).",
        "loaded":            lambda n: f"πŸ“š {n} sections extracted β€” ready to generate questions!",
        "no_pdf":            "⚠️ Please upload a PDF first.",
        "no_question":       "⚠️ Generate a question first.",
        "no_answer":         "⚠️ Please write an answer before submitting.",
    },
    "FranΓ§ais": {
        "load_btn":          "Charger le PDF",
        "new_q_btn":         "Nouvelle question",
        "submit_btn":        "Valider la rΓ©ponse",
        "pdf_label":         "πŸ“Ž Cours PDF",
        "status_label":      "Statut",
        "question_label":    "❓ Question",
        "answer_label":      "✏️ Ta réponse",
        "answer_ph":         "Γ‰cris ta rΓ©ponse ici…",
        "feedback_label":    "πŸ’¬ Feedback",
        "no_file":           "Aucun fichier sΓ©lectionnΓ©.",
        "no_chunks":         "PDF chargΓ© mais aucun texte trouvΓ© (scannΓ© ou trop court ?).",
        "loaded":            lambda n: f"πŸ“š {n} sections extraites β€” prΓͺt Γ  gΓ©nΓ©rer des questions !",
        "no_pdf":            "⚠️ Charge d'abord un PDF.",
        "no_question":       "⚠️ Génère d'abord une question.",
        "no_answer":         "⚠️ Γ‰cris une rΓ©ponse avant de valider.",
    },
}

# ---------------------------------------------------------------------------
# State helpers
# ---------------------------------------------------------------------------

def _parse_verdict(feedback: str) -> bool:
    for line in feedback.splitlines():
        low = line.lower()
        if "verdict" in low:
            return "correct" in low and "partially" not in low and "incorrect" not in low
    return False


def _score_label(correct: int, total: int) -> str:
    return f"βœ… {correct} / {total}" if total > 0 else "β€”"


def update_ui_language(language):
    s = UI[language]
    return (
        gr.update(value=s["load_btn"]),
        gr.update(value=s["new_q_btn"]),
        gr.update(value=s["submit_btn"]),
        gr.update(label=s["pdf_label"]),
        gr.update(label=s["status_label"]),
        gr.update(label=s["question_label"]),
        gr.update(label=s["answer_label"], placeholder=s["answer_ph"]),
        gr.update(label=s["feedback_label"]),
    )


def load_pdf(pdf_file, language):
    print(f"load_pdf called with: {pdf_file}")
    s = UI[language]
    if pdf_file is None:
        return [], s["no_file"], 0, 0
    try:
        text = extract_text(pdf_file.name)
        chunks = chunk_text(text)
        print(f"Extracted chunks: {len(chunks)}")
    except ValueError as e:
        print(f"load_pdf ValueError: {e}")
        return [], f"Erreur : {e}", 0, 0
    except Exception as e:
        print(f"load_pdf Exception: {e}")
        return [], f"Erreur inattendue : {e}", 0, 0
    if not chunks:
        return [], s["no_chunks"], 0, 0
    return chunks, s["loaded"](len(chunks)), 0, 0


@gpu_if_needed(duration=180)
def new_question(chunks, language):
    print(f"[new_question] called β€” {len(chunks)} chunks, lang={language}")
    s = UI[language]
    if not chunks:
        print("[new_question] no chunks, returning early")
        return s["no_pdf"], "", s["no_pdf"], ""
    try:
        chunk = random.choice(chunks)
        print(f"[new_question] chunk selected ({len(chunk)} chars), calling LLM…")
        question = generate_question(chunk, language=language)
        print(f"[new_question] question generated: {question[:80]!r}")
        sentences = [s.strip() for s in chunk.replace('\n', ' ').split('.') if s.strip()]
        preview = '. '.join(sentences[:3]) + ('.' if sentences else '')
        if len(preview) > 400:
            preview = preview[:400].rsplit(' ', 1)[0] + '…'
        return question, chunk, "", preview
    except Exception as e:
        import traceback; traceback.print_exc()
        err = f"Erreur : {e}"
        return err, "", err, ""


@gpu_if_needed(duration=180)
def new_mcq(chunks, language):
    import json as _json
    print(f"[new_mcq] called β€” {len(chunks)} chunks, lang={language}")
    s = UI[language]
    if not chunks:
        return s["no_pdf"], "", _json.dumps({}), ""
    try:
        chunk = random.choice(chunks)
        print(f"[new_mcq] chunk selected ({len(chunk)} chars), calling LLM…")
        mcq = generate_mcq(chunk, language=language)
        print(f"[new_mcq] MCQ generated: {mcq.get('question','?')[:60]!r}")
        sentences = [x.strip() for x in chunk.replace('\n', ' ').split('.') if x.strip()]
        preview = '. '.join(sentences[:3]) + ('.' if sentences else '')
        if len(preview) > 400:
            preview = preview[:400].rsplit(' ', 1)[0] + '…'
        return mcq.get("question", ""), chunk, _json.dumps(mcq), preview
    except Exception as e:
        import traceback; traceback.print_exc()
        err = f"Erreur : {e}"
        return err, "", _json.dumps({"error": err}), ""


@gpu_if_needed(duration=120)
def submit_answer(question, chunk, student_answer, correct, total, language):
    s = UI[language]
    if not question:
        return s["no_question"], correct, total, _score_label(correct, total)
    if not student_answer.strip():
        return s["no_answer"], correct, total, _score_label(correct, total)
    try:
        feedback = evaluate_answer(question, chunk, student_answer, language=language)
        total += 1
        if _parse_verdict(feedback):
            correct += 1
        return feedback, correct, total, _score_label(correct, total)
    except Exception as e:
        return f"Erreur : {e}", correct, total, _score_label(correct, total)


@spaces.GPU(duration=120)
def generate_image_fn(topics):
    if not topics or not topics.strip():
        return None
    try:
        prompt = f"Visual summary of a study session covering: {topics.strip()}"
        print(f"[generate_image_fn] generating session illustration for: {prompt[:120]!r}")
        return generate_concept_image(prompt)
    except Exception as e:
        import traceback; traceback.print_exc()
        print(f"[generate_image_fn] failed: {e}")
        return None


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------

BRIDGE_JS = """() => {
  // ── State ──────────────────────────────────────────────────────────────────
  const S = {
    correct: 0, total: 0, history: [],
    currentQuestion: '',
    quizRevealed: false,
    lastStatus: '',
    waitingQ: false, prevQuestion: '',
    waitingFB: false, prevFeedback: '',
    mode: 'open',
    mcqData: null,
    waitingMCQ: false, prevMCQ: '',
    imgPoll: null, imgRequested: false,
  };
  const $ = id => document.getElementById(id);

  // ── Status bar ─────────────────────────────────────────────────────────────
  function ensureStatusBar() {
    if ($('gradio-status-bar')) return;
    const anchor = $('quiz-card') || $('app');
    if (!anchor) return;
    const bar = document.createElement('div');
    bar.id = 'gradio-status-bar';
    bar.style.cssText = 'display:none;max-width:760px;margin:8px auto 0;padding:12px 20px;border-radius:10px;font-size:.9rem;font-weight:500;font-family:Inter,system-ui,sans-serif;transition:all .3s ease;';
    anchor.parentNode.insertBefore(bar, anchor);
  }

  function applyStatus(text) {
    if (text === S.lastStatus) return;
    S.lastStatus = text;
    ensureStatusBar();
    const bar = $('gradio-status-bar');
    if (!bar) return;
    bar.textContent = text;
    bar.style.display = 'block';
    const isErr = text.includes('\\u26a0') || text.toLowerCase().includes('erreur');
    const isOK  = text.includes('sections') || text.includes('extracted') || text.includes('extraites');
    if (isErr) {
      bar.style.cssText += ';background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);color:#FCA5A5';
    } else if (isOK) {
      bar.style.cssText += ';background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.3);color:#6EE7B7';
      if (!S.quizRevealed) { S.quizRevealed = true; revealQuiz(); }
    } else {
      bar.style.cssText += ';background:rgba(6,182,212,.12);border:1px solid rgba(6,182,212,.3);color:#67E8F9';
    }
  }

  function revealQuiz() {
    const qc = $('quiz-card');
    if (qc) { qc.classList.remove('hidden'); setTimeout(() => qc.scrollIntoView({behavior:'smooth',block:'start'}), 400); }
    const ur = document.querySelector('#upload-row');
    if (ur) { ur.style.cssText += ';opacity:0;height:0;overflow:hidden;margin:0;padding:0;pointer-events:none;transition:all .4s ease'; }
  }

  // ── Helpers ────────────────────────────────────────────────────────────────
  function setGradioTA(sel, val) {
    const el = document.querySelector(sel);
    if (!el) return false;
    Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(el, val);
    el.dispatchEvent(new Event('input',  {bubbles:true}));
    el.dispatchEvent(new Event('change', {bubbles:true}));
    return true;
  }
  function clickGradioBtn(sel) {
    const el = document.querySelector(sel);
    if (!el) return false;
    const btn = el.tagName === 'BUTTON' ? el : el.querySelector('button');
    if (btn) { btn.click(); return true; } return false;
  }

  // ── Hero: professor eye tracking ───────────────────────────────────────────
  // (Lives here, not in index.html: <script> tags injected via gr.HTML's
  //  innerHTML are never executed by browsers.)
  function initHeroProfessor() {
    const prof = $('professor');
    if (!prof || prof._pp) return;
    prof._pp = true;
    const pupils = prof.querySelectorAll('.prof-pupil');
    let tx = 0, ty = 0, cx = 0, cy = 0;
    document.addEventListener('mousemove', e => {
      const r = prof.getBoundingClientRect();
      // eyes sit in the head, around the top third of the character
      const ox = r.left + r.width / 2, oy = r.top + r.height * 0.32;
      const dx = e.clientX - ox, dy = e.clientY - oy;
      const d = Math.min(4, Math.hypot(dx, dy) / 40); // max 4px displacement
      const a = Math.atan2(dy, dx);
      tx = Math.cos(a) * d; ty = Math.sin(a) * d;
    });
    (function track() {
      cx += (tx - cx) * 0.18; cy += (ty - cy) * 0.18;
      const t = 'translate(' + cx.toFixed(2) + 'px,' + cy.toFixed(2) + 'px)';
      pupils.forEach(p => { p.style.transform = t; });
      requestAnimationFrame(track);
    })();
  }

  // ── Library wings: span the whole page, repeat content to the bottom ──────
  // position:fixed breaks under Gradio's ancestor transforms, so the wings are
  // re-parented onto <body>, stretched to the page height, and their .lib-unit
  // content is cloned as many times as needed to fill it.
  function syncLibraryWings() {
    const wings = document.querySelectorAll('.library');
    if (!wings.length) return;
    wings.forEach(w => { if (w.parentElement !== document.body) document.body.appendChild(w); });
    const gc = document.querySelector('.gradio-container');
    const H = Math.max(gc ? gc.scrollHeight : 0, window.innerHeight);
    wings.forEach(w => {
      if (parseInt(w.style.height, 10) !== H) w.style.height = H + 'px';
      const first = w.querySelector('.lib-unit');
      if (!first) return;
      const per = first.offsetHeight || 950;
      const need = Math.max(1, Math.ceil(H / per));
      let units = w.querySelectorAll('.lib-unit');
      for (let i = units.length; i < need; i++) w.appendChild(first.cloneNode(true));
      units = w.querySelectorAll('.lib-unit');
      for (let i = units.length - 1; i >= need; i--) units[i].remove();
    });
  }

  // ── Force the Gradio upload zone to English (it localises to the browser) ──
  function enforceEnglishUpload() {
    const row = document.querySelector('#upload-row');
    if (!row || !/D\\u00e9posez|Cliquez pour|Glissez/.test(row.textContent)) return;
    const tw = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = tw.nextNode())) {
      let t = n.nodeValue;
      t = t.replace(/(D\\u00e9posez|Glissez-d\\u00e9posez) le fichier ici/g, 'Drop your PDF here');
      t = t.replace(/-\\s*ou\\s*-/g, '- or -');
      t = t.replace(/Cliquez pour t\\u00e9l\\u00e9(charger|verser) un fichier/g, 'Click to upload');
      if (t !== n.nodeValue) n.nodeValue = t;
    }
  }

  // ── Background particles (canvas is in gr.HTML markup; animation here) ────
  function initParticles() {
    const canvas = $('particles');
    if (!canvas || canvas._pp) return;
    canvas._pp = true;
    const ctx = canvas.getContext('2d');
    let W, H;
    const rand = (a, b) => a + Math.random() * (b - a);
    function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
    window.addEventListener('resize', resize);
    resize();
    const parts = Array.from({length: 65}, () => ({
      x: rand(0, W), y: rand(0, H),
      vx: rand(-.25, .25), vy: rand(-.25, .25),
      r: rand(1, 2.2),
      purple: Math.random() > .5,
      alpha: rand(.15, .55),
    }));
    (function draw() {
      ctx.clearRect(0, 0, W, H);
      for (let i = 0; i < parts.length; i++) {
        const p = parts[i];
        p.x += p.vx; p.y += p.vy;
        if (p.x < -5) p.x = W + 5; if (p.x > W + 5) p.x = -5;
        if (p.y < -5) p.y = H + 5; if (p.y > H + 5) p.y = -5;
        for (let j = i + 1; j < parts.length; j++) {
          const q = parts[j];
          const dx = p.x - q.x, dy = p.y - q.y;
          const dist = Math.hypot(dx, dy);
          if (dist < 110) {
            ctx.beginPath();
            ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
            ctx.strokeStyle = 'rgba(124,58,237,' + ((1 - dist / 110) * 0.12) + ')';
            ctx.lineWidth = 1;
            ctx.stroke();
          }
        }
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(' + (p.purple ? '124,58,237' : '6,182,212') + ',' + p.alpha + ')';
        ctx.fill();
      }
      requestAnimationFrame(draw);
    })();
  }

  // ── Score ring ─────────────────────────────────────────────────────────────
  function updateScore() {
    const arc   = $('score-arc');
    const label = $('score-label');
    if (!arc || !label) return;
    const n = S.correct, d = S.total;
    label.textContent = d === 0 ? 'β€”' : n + '/' + d;
    arc.style.strokeDashoffset = 138.2 * (1 - (d ? n/d : 0));
    arc.style.stroke = d === 0 ? 'url(#scoreGrad)' : (n/d >= .7 ? '#10B981' : n/d >= .4 ? '#F59E0B' : '#EF4444');
  }

  // ── Feedback rendering ─────────────────────────────────────────────────────
  function extractVerdict(t) {
    const lo = t.toLowerCase(), lines = lo.split('\\n');
    for (const l of lines) {
      if (l.includes('verdict')) {
        if (l.includes('partially')) return 'partial';
        if (l.includes('incorrect'))  return 'incorrect';
        if (l.includes('correct'))    return 'correct';
      }
    }
    const h = lo.slice(0,200);
    return h.includes('incorrect') ? 'incorrect' : h.includes('partially') ? 'partial' : h.includes('correct') ? 'correct' : 'unknown';
  }

  function showFeedback(text) {
    const badge = $('verdict-badge'), fc = $('feedback-card'), body = $('feedback-body');
    const verdict = extractVerdict(text);
    if (badge) {
      badge.className = 'verdict-badge ' + (verdict === 'unknown' ? '' : verdict);
      badge.textContent = verdict === 'correct' ? 'βœ“ Correct' : verdict === 'partial' ? '~ Partially Correct' : verdict === 'incorrect' ? 'βœ— Incorrect' : 'β€” Unknown';
    }
    if (body) {
      const labels = ['','Verdict','What was good','What was missing','Model answer'];
      const sections = [], re = /(\\d+)\\.\\s*([^:\\n]*)[::]?\\s*([\\s\\S]*?)(?=\\n\\d+\\.|$)/g;
      let m;
      while ((m = re.exec(text)) !== null) sections.push({num:+m[1], body:m[3].trim().replace(/^\\d+\\.\\s*/,'')});
      body.innerHTML = sections.length >= 2
        ? sections.map(s => '<div class="section"><span class="section-num">'+(labels[s.num]||'Part '+s.num)+'</span><div class="section-content">'+s.body.replace(/\\n/g,'<br>')+'</div></div>').join('')
        : '<div class="feedback-raw">'+text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div>';
    }
    const chunkEl = document.querySelector('#hidden-chunk-output textarea');
    const srcEl = document.getElementById('source-text');
    if (srcEl && chunkEl) srcEl.textContent = chunkEl.value;
    if (fc) { fc.classList.remove('hidden'); fc.classList.add('fade-in-up'); setTimeout(() => fc.scrollIntoView({behavior:'smooth',block:'nearest'}),100); }
    return verdict;
  }

  // ── Session reward image (generic encouraging illustration) ───────────────
  // Pre-generated in the background as soon as the first question shows, so
  // the End Session modal can display it instantly.
  function requestSessionImage() {
    if (S.imgRequested) return;
    if (!setGradioTA('#hidden-session-topics textarea', 'study session')) return;
    S.imgRequested = true;
    // Small delay so Gradio registers the input before the click
    setTimeout(() => clickGradioBtn('#hidden-generate-session-image-btn'), 200);
  }

  function stopSessionImagePoll() {
    if (S.imgPoll) { clearInterval(S.imgPoll); S.imgPoll = null; }
    $('modal-image-loading')?.classList.add('hidden');
  }

  function showSessionImage() {
    const sec = $('modal-session-image'), loading = $('modal-image-loading'), img = $('session-image');
    if (!sec || !loading || !img) return;
    stopSessionImagePoll();
    sec.classList.add('hidden'); sec.classList.remove('fade-in-up');
    const ready = document.querySelector('#hidden-concept-image img');
    if (ready && ready.src) { // preloaded β€” show instantly
      img.src = ready.src;
      sec.classList.remove('hidden');
      return;
    }
    requestSessionImage(); // fallback in case the preload never fired
    loading.classList.remove('hidden');
    const started = Date.now();
    S.imgPoll = setInterval(() => {
      const el = document.querySelector('#hidden-concept-image img');
      if (el && el.src) {
        stopSessionImagePoll();
        img.src = el.src;
        sec.classList.remove('hidden');
        void sec.offsetWidth; // restart fade-in animation
        sec.classList.add('fade-in-up');
      } else if (Date.now() - started > 120000) {
        stopSessionImagePoll(); // give up quietly after 2 min
      }
    }, 500);
  }

  // ── Question display ───────────────────────────────────────────────────────
  function showQuestion(text) {
    S.currentQuestion = text; S.waitingQ = false;
    const es = $('empty-state'), qa = $('question-area'), qt = $('q-text'), ql = $('q-loading');
    const eb = $('end-btn'), fc = $('feedback-card'), ai = $('answer-input'), cc = $('char-count');
    const sb = $('submit-btn'), nq = $('new-q-btn');
    if (es) es.classList.add('hidden');
    if (qa) qa.classList.remove('hidden');
    if (eb) eb.classList.remove('hidden');
    if (ql) ql.classList.add('hidden');
    if (qt) qt.textContent = text;
    if (fc) fc.classList.add('hidden');
    if (ai) { ai.value = ''; ai.disabled = false; }
    if (cc) cc.textContent = '0 chars';
    if (sb) sb.disabled = true;
    if (nq) nq.disabled = false;
    $('mcq-options')?.classList.add('hidden');
    $('answer-wrapper')?.classList.remove('hidden');
    requestSessionImage(); // generate the reward image while the user answers
  }

  // ── Language toggle ────────────────────────────────────────────────────────
  function setLanguage(lang) {
    setGradioTA('#hidden-language-box textarea', lang);
    $('lang-fr-btn')?.classList.toggle('active', lang === 'FranΓ§ais');
    $('lang-en-btn')?.classList.toggle('active', lang === 'English');
  }

  // ── Mode toggle ────────────────────────────────────────────────────────────
  function setMode(m) {
    S.mode = m;
    $('mode-open-btn')?.classList.toggle('selected', m === 'open');
    $('mode-mcq-btn')?.classList.toggle('selected', m === 'mcq');
    S.currentQuestion = ''; S.mcqData = null;
    $('empty-state')?.classList.remove('hidden');
    $('question-area')?.classList.add('hidden');
    $('feedback-card')?.classList.add('hidden');
  }

  // ── MCQ display ────────────────────────────────────────────────────────────
  function showMCQ(data) {
    S.mcqData = data; S.waitingMCQ = false; S.mcqAnswered = false;
    const qt = $('q-text'), ql = $('q-loading'), nq = $('new-q-btn');
    const es = $('empty-state'), qa = $('question-area'), eb = $('end-btn');
    if (es) es.classList.add('hidden');
    if (qa) qa.classList.remove('hidden');
    if (eb) eb.classList.remove('hidden');
    if (ql) ql.classList.add('hidden');
    if (qt) qt.textContent = data.question || '';
    if (nq) nq.disabled = false;
    for (const l of ['A','B','C','D']) {
      const txt = $('mcq-text-'+l.toLowerCase());
      const btn = $('mcq-'+l.toLowerCase());
      if (txt) txt.textContent = (data.choices && data.choices[l]) || '';
      if (btn) {
        btn.className = 'mcq-btn'; btn.disabled = false;
        btn.onclick = ((letter) => () => handleMCQAnswer(letter))(l);
      }
    }
    $('mcq-options')?.classList.remove('hidden');
    requestSessionImage(); // generate the reward image while the user answers
    $('mcq-explanations')?.classList.add('hidden');
    $('answer-wrapper')?.classList.add('hidden');
    $('feedback-card')?.classList.add('hidden');
  }

  // ── MCQ answer handler (client-side β€” no LLM call needed) ─────────────────
  function handleMCQAnswer(letter) {
    const data = S.mcqData;
    if (!data || !data.correct || S.waitingMCQ || S.mcqAnswered) return;
    S.mcqAnswered = true;
    const correct = data.correct;
    const isCorrect = letter === correct;
    for (const l of ['A','B','C','D']) {
      const btn = $('mcq-'+l.toLowerCase());
      if (!btn) continue;
      btn.disabled = true;
      if (l === correct) btn.classList.add('mcq-correct');
      else if (l === letter) btn.classList.add('mcq-wrong');
      else btn.classList.add('mcq-neutral');
    }
    const expEl = $('mcq-explanations');
    if (expEl && data.explanations) {
      expEl.innerHTML = ['A','B','C','D'].map(l => {
        const exp = data.explanations[l]; if (!exp) return '';
        const cls = l===correct ? 'exp-correct' : l===letter ? 'exp-wrong' : 'exp-neutral';
        return '<div class="mcq-exp '+cls+'"><span class="mcq-exp-letter">'+l+'</span><span class="mcq-exp-text">'+exp+'</span></div>';
      }).join('');
      expEl.classList.remove('hidden');
    }
    S.total++; if (isCorrect) S.correct++;
    S.currentQuestion = data.question || '';
    S.history.push({question: S.currentQuestion, verdict: isCorrect ? 'correct' : 'incorrect'});
    updateScore();
    const chunkEl = document.querySelector('#hidden-chunk-output textarea');
    const srcEl = document.getElementById('source-text');
    if (srcEl && chunkEl) srcEl.textContent = chunkEl.value;
    const badge=$('verdict-badge'), fc=$('feedback-card'), body=$('feedback-body');
    if (badge) {
      badge.className = 'verdict-badge '+(isCorrect?'correct':'incorrect');
      badge.textContent = isCorrect ? 'βœ“ Correct' : 'βœ— Incorrect';
    }
    if (body) body.innerHTML = '';
    if (fc) { fc.classList.remove('hidden'); fc.classList.add('fade-in-up'); setTimeout(()=>fc.scrollIntoView({behavior:'smooth',block:'nearest'}),100); }
  }

  // ── Trigger actions via hidden Gradio components ───────────────────────────
  function fetchQuestion() {
    if (S.waitingQ || S.waitingMCQ) return;
    S.waitingQStart = Date.now();
    const ql = $('q-loading'), qm = $('q-loading-msg'), nq = $('new-q-btn'), fc = $('feedback-card');
    const es = $('empty-state'), qa = $('question-area'), eb = $('end-btn');
    if (ql) ql.classList.remove('hidden');
    if (qm) qm.textContent = S.total === 0 ? 'Model loading (~30–60s first time)…' : (S.mode==='mcq' ? 'Generating MCQ…' : 'Generating question…');
    if (nq) nq.disabled = true;
    if (fc) fc.classList.add('hidden');
    if (es) es.classList.add('hidden');
    if (qa) qa.classList.remove('hidden');
    if (eb) eb.classList.remove('hidden');
    if (S.mode === 'mcq') {
      S.waitingMCQ = true;
      S.prevMCQ = document.querySelector('#hidden-mcq-output textarea')?.value || '';
      const clicked = setGradioTA('#hidden-mcq-trigger textarea', Date.now().toString());
      if (!clicked) applyStatus('⚠️ Could not reach Gradio backend β€” try reloading the page.');
    } else {
      S.waitingQ = true;
      S.prevQuestion = document.querySelector('#hidden-question-output textarea')?.value || '';
      const clicked = setGradioTA('#hidden-question-trigger textarea', Date.now().toString());
      if (!clicked) applyStatus('⚠️ Could not reach Gradio backend β€” try reloading the page.');
    }
  }

  function doSubmit() {
    const ai = $('answer-input');
    const answer = ai?.value?.trim();
    if (!answer || !S.currentQuestion || S.waitingFB) return;
    S.waitingFB = true;
    S.prevFeedback = document.querySelector('#hidden-feedback-output textarea')?.value || '';
    const sb = $('submit-btn'), st = $('submit-btn-text'), ss = $('submit-spinner'), nq = $('new-q-btn');
    if (sb) sb.disabled = true;
    if (st) st.textContent = 'Evaluating…';
    if (ss) ss.classList.remove('hidden');
    if (nq) nq.disabled = true;
    setGradioTA('#hidden-answer-input textarea', answer);
    setTimeout(() => setGradioTA('#hidden-answer-trigger textarea', Date.now().toString()), 200);
  }

  // ── Modal ──────────────────────────────────────────────────────────────────
  function showModal() {
    const modal = $('modal');
    if (!modal) return;
    const pct = S.total > 0 ? Math.round(S.correct/S.total*100) : 0;
    const msb = $('modal-score-big'), msl = $('modal-score-label'), mm = $('modal-message'), mh = $('modal-history');
    if (msb) msb.textContent = S.correct+'/'+S.total;
    if (msl) msl.textContent = S.total === 1 ? 'question answered' : 'questions answered';
    if (mm) mm.textContent = pct>=90 ? "Outstanding! You've mastered this material. \\uD83C\\uDF1F"
      : pct>=70 ? "Great work β€” solid grasp of the content. \\uD83D\\uDCAA"
      : pct>=50 ? "Good start! Keep practising the tricky sections. \\uD83D\\uDCD6"
      : "Keep going β€” each question makes you stronger. \\uD83D\\uDD25";
    if (mh && S.history.length > 0) {
      mh.classList.remove('hidden');
      mh.innerHTML = S.history.slice(-8).map(h => {
        const vc = h.verdict==='correct'?'correct':h.verdict==='partial'?'partial':'incorrect';
        return '<div class="modal-history-item"><span class="hist-badge '+vc+'">'+(vc==='correct'?'βœ“':vc==='partial'?'~':'βœ—')+'</span><span class="hist-q">'+h.question+'</span></div>';
      }).join('');
    }
    showSessionImage();
    modal.classList.remove('hidden');
  }

  // ── Wire HTML buttons ──────────────────────────────────────────────────────
  function wireButtons() {
    const pairs = [
      ['start-btn',    () => fetchQuestion()],
      ['new-q-btn',    () => fetchQuestion()],
      ['submit-btn',   () => doSubmit()],
      ['mode-open-btn',() => setMode('open')],
      ['mode-mcq-btn', () => setMode('mcq')],
      ['lang-fr-btn',  () => setLanguage('FranΓ§ais')],
      ['lang-en-btn',  () => setLanguage('English')],
      ['end-btn',      () => { if (S.total===0) { const m=$('modal'); if(m) m.classList.remove('hidden'); } else showModal(); }],
      ['modal-close',  () => {
        const modal=$('modal'); if(modal) modal.classList.add('hidden');
        S.correct=0; S.total=0; S.history=[]; S.currentQuestion=''; S.mcqData=null;
        updateScore();
        const ai=$('answer-input'), cc=$('char-count'), sb=$('submit-btn'), fc=$('feedback-card');
        const es=$('empty-state'), qa=$('question-area'), eb=$('end-btn'), qc=$('quiz-card');
        if(ai){ai.value='';} if(cc) cc.textContent='0 chars'; if(sb) sb.disabled=true;
        if(fc) fc.classList.add('hidden'); if(es) es.classList.remove('hidden');
        if(qa) qa.classList.add('hidden'); if(eb) eb.classList.add('hidden');
        stopSessionImagePoll();
        $('modal-session-image')?.classList.add('hidden');
        if(qc) qc.scrollIntoView({behavior:'smooth',block:'start'});
      }],
    ];
    for (const [id, fn] of pairs) {
      const el = $(id);
      if (el && !el._pp) { el._pp = true; el.addEventListener('click', fn); }
    }
    const modal = $('modal');
    if (modal && !modal._pp) { modal._pp = true; modal.addEventListener('click', e => { if(e.target===modal) { modal.classList.add('hidden'); stopSessionImagePoll(); } }); }
    const ai = $('answer-input'), sb = $('submit-btn'), cc = $('char-count');
    if (ai && !ai._pp) {
      ai._pp = true;
      ai.addEventListener('input', () => {
        if(cc) cc.textContent = ai.value.length+' chars';
        if(sb) sb.disabled = ai.value.trim().length===0 || !S.currentQuestion;
      });
    }
  }

  // ── Main polling loop ──────────────────────────────────────────────────────
  setInterval(function() {
    wireButtons();
    initHeroProfessor();
    initParticles();
    syncLibraryWings();
    enforceEnglishUpload();

    const statusEl = document.querySelector('#hidden-status-output textarea');
    if (statusEl && statusEl.value) applyStatus(statusEl.value);

    // Question output β€” picked up even after a timeout (CPU runtime can be
    // slower than the spinner's patience; a late result is still a result).
    {
      const qEl = document.querySelector('#hidden-question-output textarea');
      if (qEl && qEl.value && qEl.value !== S.prevQuestion) {
        const val = qEl.value;
        S.prevQuestion = val;
        const isErr = val.startsWith('Erreur') || val.startsWith('⚠️');
        if (isErr) {
          S.waitingQ = false;
          const ql=$('q-loading'), nq=$('new-q-btn');
          if(ql) ql.classList.add('hidden');
          if(nq) nq.disabled=false;
          applyStatus(val);
        } else if (S.mode === 'open' && (S.waitingQ || S.quizRevealed)) {
          showQuestion(val);
        }
      }
    }

    if (S.waitingQ) {
      const elapsed = Math.round((Date.now() - S.waitingQStart) / 1000);
      const qm = $('q-loading-msg');
      if (qm) {
        if (elapsed < 15) qm.textContent = 'Generating question…';
        else if (elapsed < 90) qm.textContent = 'Model working… (' + elapsed + 's)';
        else if (elapsed < 240) qm.textContent = 'Still working… (' + elapsed + 's) β€” CPU runtime can take a few minutes';
        else qm.textContent = 'Very slow… (' + elapsed + 's) β€” check Space logs if this persists';
      }
      // Hard timeout: 5 min β€” unlock UI (a late result will still display)
      if (elapsed > 300) {
        S.waitingQ = false;
        const ql=$('q-loading'), nq=$('new-q-btn');
        if(ql) ql.classList.add('hidden');
        if(nq) nq.disabled=false;
        applyStatus('⚠️ Request timed out (' + elapsed + 's). The question will appear if it finishes β€” or try again.');
      }
    }

    // MCQ output β€” same late-pickup logic as questions.
    {
      const mcqEl = document.querySelector('#hidden-mcq-output textarea');
      if (mcqEl && mcqEl.value && mcqEl.value !== S.prevMCQ) {
        S.prevMCQ = mcqEl.value;
        try {
          const data = JSON.parse(mcqEl.value);
          if (data && data.question && S.mode === 'mcq' && (S.waitingMCQ || S.quizRevealed)) showMCQ(data);
          else if (S.waitingMCQ) { S.waitingMCQ=false; $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false; applyStatus('⚠️ MCQ generation failed.'); }
        } catch(e) { if (S.waitingMCQ) { S.waitingMCQ=false; $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false; } }
      }
    }

    if (S.waitingMCQ) {
      const elapsed = Math.round((Date.now() - S.waitingQStart) / 1000);
      const qm = $('q-loading-msg');
      if (qm) {
        if (elapsed<15) qm.textContent='Generating MCQ…';
        else if (elapsed<90) qm.textContent='Model working… ('+elapsed+'s)';
        else if (elapsed<240) qm.textContent='Still working… ('+elapsed+'s) β€” CPU runtime can take a few minutes';
        else qm.textContent='Very slow… ('+elapsed+'s) β€” check Space logs';
      }
      if (elapsed>300) {
        S.waitingMCQ=false;
        $('q-loading')?.classList.add('hidden'); $('new-q-btn').disabled=false;
        applyStatus('⚠️ MCQ timed out ('+elapsed+'s). It will appear if it finishes β€” or try again.');
      }
    }

    if (S.waitingFB) {
      const fbEl = document.querySelector('#hidden-feedback-output textarea');
      if (fbEl && fbEl.value && fbEl.value !== S.prevFeedback) {
        const text = fbEl.value;
        S.prevFeedback = text; S.waitingFB = false;
        const sb=$('submit-btn'), st=$('submit-btn-text'), ss=$('submit-spinner'), nq=$('new-q-btn');
        if(st) st.textContent='Submit Answer'; if(ss) ss.classList.add('hidden');
        if(sb) sb.disabled=false; if(nq) nq.disabled=false;
        const verdict = showFeedback(text);
        S.total++;
        if (verdict==='correct') S.correct++;
        S.history.push({question:S.currentQuestion, verdict});
        updateScore();
      }
    }

  }, 300);
}"""

print("βœ… Starting Gradio app...")
with gr.Blocks(title="PaperProf", css=CSS) as demo:

    gr.HTML(CUSTOM_HTML)
    demo.load(None, js=BRIDGE_JS)

    chunks_state  = gr.State([])
    chunk_state   = gr.State("")
    correct_state = gr.State(0)
    total_state   = gr.State(0)

    with gr.Row(elem_id="upload-row"):
        pdf_input         = gr.File(label="πŸ“Ž Cours PDF", file_types=[".pdf"], scale=3)
        load_btn          = gr.Button("Load PDF", variant="primary", scale=2, elem_id="hidden-load-btn")
        language_selector = gr.Radio(["English", "Français"], value="English", label="🌐 Language", visible=False)

    with gr.Row(elem_id="hidden-row-status"):
        load_status = gr.Textbox(label="Status", interactive=False, scale=4, elem_id="hidden-status-output")
        score_box   = gr.Textbox(label="Score", value="β€”", interactive=False, scale=1, elem_id="score-box", visible=False)

    with gr.Row(elem_id="hidden-row-question"):
        question_box = gr.Textbox(label="❓ Question", interactive=False, lines=3, scale=3, elem_id="hidden-question-output")
        new_q_btn    = gr.Button("New Question", variant="secondary", scale=1, elem_id="hidden-question-btn")

    answer_box       = gr.Textbox(label="✏️ Your Answer", lines=4, placeholder="Write your answer here…", elem_id="hidden-answer-input")
    submit_btn       = gr.Button("Submit Answer", variant="primary", elem_id="hidden-submit-btn")
    feedback_box     = gr.Textbox(label="πŸ’¬ Feedback", interactive=False, lines=7, elem_id="hidden-feedback-output")
    concept_image    = gr.Image(label="concept", type="pil", interactive=False, visible=True, elem_id="hidden-concept-image")
    question_trigger = gr.Textbox(value="0",       label="qtrig",   elem_id="hidden-question-trigger")
    answer_trigger   = gr.Textbox(value="0",       label="atrig",   elem_id="hidden-answer-trigger")
    chunk_display    = gr.Textbox(value="",        label="chunk",   elem_id="hidden-chunk-output")
    mcq_trigger      = gr.Textbox(value="0",       label="mcqtrig", elem_id="hidden-mcq-trigger")
    mcq_output       = gr.Textbox(value="",        label="mcqout",  elem_id="hidden-mcq-output")
    language_box     = gr.Textbox(value="English", label="lang", elem_id="hidden-language-box")
    session_topics   = gr.Textbox(value="",        label="topics",  elem_id="hidden-session-topics")
    session_img_btn  = gr.Button("gen session image", elem_id="hidden-generate-session-image-btn")

    load_btn.click(
        load_pdf,
        inputs=[pdf_input, language_selector],
        outputs=[chunks_state, load_status, correct_state, total_state],
    ).then(lambda: "β€”", outputs=[score_box])

    question_trigger.change(
        new_question,
        inputs=[chunks_state, language_box],
        outputs=[question_box, chunk_state, feedback_box, chunk_display],
    )

    answer_trigger.change(
        submit_answer,
        inputs=[question_box, chunk_state, answer_box, correct_state, total_state, language_box],
        outputs=[feedback_box, correct_state, total_state, score_box],
    )

    session_img_btn.click(
        generate_image_fn,
        inputs=[session_topics],
        outputs=[concept_image],
    )

    mcq_trigger.change(
        new_mcq,
        inputs=[chunks_state, language_box],
        outputs=[question_box, chunk_state, mcq_output, chunk_display],
    )

print("βœ… Gradio demo defined successfully")

print("βœ… Launching demo...")
demo.launch(server_name="0.0.0.0", server_port=7860)