Toya0421 commited on
Commit
cbec41e
·
verified ·
1 Parent(s): f57cf7d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +207 -125
app.py CHANGED
@@ -4,27 +4,92 @@ from datasets import Dataset
4
  from datetime import datetime, timedelta
5
  import pandas as pd
6
  import time, os, random, tempfile, json, glob
 
 
7
 
8
  # --- API / HF 設定 ---
9
  API_KEY = os.getenv("API_KEY")
10
  BASE_URL = "https://openrouter.ai/api/v1"
11
  HF_TOKEN = os.getenv("HF_TOKEN")
12
  DATASET_REPO = "Toya0421/reading_exercise_logging"
13
- LOG_FILE = "reading_logs.csv"
 
 
14
 
15
  client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
16
 
17
  # --- passage_information.xlsx 読み込み (Text# と flesch_score 使用) ---
18
  passage_info_df = pd.read_excel("passage_information.xlsx")
19
 
20
- # --- 状態変数 ---
21
- used_passages = set()
22
- current_user_id = None
23
- current_level = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  # ======================================================
27
  # 新しい教材管理:passages フォルダからランダム選択
 
28
  # ======================================================
29
 
30
  def load_passage_file(text_id):
@@ -37,19 +102,15 @@ def load_passage_file(text_id):
37
  with open(path, "r", encoding="utf-8") as f:
38
  return f.read()
39
 
40
- def get_new_passage_random():
41
  """
42
  passages フォルダからランダムに教材を選び(pg◯.txt)、
43
  passage_information.xlsx の Text# の flesch_score を original_level として返す。
44
  """
45
- global used_passages
46
-
47
- # --- pg*.txt を取得 ---
48
  files = glob.glob("passages/pg*.txt")
49
  if not files:
50
- return None, None, None
51
 
52
- # --- ファイル名から Text# (整数) を抽出 ---
53
  all_ids = []
54
  for f in files:
55
  name = os.path.basename(f)
@@ -57,33 +118,28 @@ def get_new_passage_random():
57
  if num.isdigit():
58
  all_ids.append(int(num))
59
 
60
- # --- 未使用の ID を優先 ---
61
- available = [pid for pid in all_ids if pid not in used_passages]
62
  if not available:
63
- used_passages.clear()
64
  available = list(all_ids)
65
 
66
- # --- ランダムに選択 ---
67
  text_id = random.choice(available)
68
- used_passages.add(text_id)
69
 
70
- # --- テキスト読み込み ---
71
  text = load_passage_file(text_id)
72
  if text is None:
73
- return None, None, None
74
 
75
- # --- Excel から original_level (flesch_score) を取得 ---
76
  row = passage_info_df[passage_info_df["Text#"] == text_id]
77
  if len(row) == 0:
78
  orig_level = None
79
  else:
80
  orig_level = row.iloc[0]["flesch_score"]
81
 
82
- return text_id, text, orig_level
83
-
84
 
85
  # ======================================================
86
- # Rewrite
87
  # ======================================================
88
 
89
  def rewrite_level(text, target_level):
@@ -108,24 +164,22 @@ excluding the title, author name, source information, chapter number, annotation
108
  {text}
109
  """
110
 
111
- resp = client.chat.completions.create(
112
- model="google/gemini-2.5-flash",
113
- messages=[{"role": "user", "content": prompt}],
114
- temperature=0.4,
115
- max_tokens=5000
116
- )
 
 
117
  return resp.choices[0].message.content.strip()
118
 
119
-
120
- import re
121
-
122
  def split_pages(text, max_words=300):
123
  """
124
  文単位でページを分割する。
125
  - 文の途中でページを分割しない
126
  - max_words の上限を超えないようにする
127
  """
128
- # 文に分割(. ? ! のあとに改行やスペースが続くパターン)
129
  sentences = re.split(r'(?<=[.!?])\s+', text.strip())
130
  pages = []
131
  current_page = []
@@ -135,7 +189,6 @@ def split_pages(text, max_words=300):
135
  words = sentence.split()
136
  sentence_len = len(words)
137
 
138
- # 次の文を追加すると max_words を超える場合 → 新しいページを作る
139
  if current_word_count + sentence_len > max_words:
140
  if current_page:
141
  pages.append(" ".join(current_page))
@@ -145,46 +198,50 @@ def split_pages(text, max_words=300):
145
  current_page.append(sentence)
146
  current_word_count += sentence_len
147
 
148
- # 最後のページを追加
149
  if current_page:
150
  pages.append(" ".join(current_page))
151
 
152
  return pages or [text]
153
 
154
-
155
  # ======================================================
156
- # Save Log
157
  # ======================================================
158
 
159
- def save_log(entry):
160
- df = pd.DataFrame([entry])
161
- if os.path.exists(LOG_FILE):
162
- df.to_csv(LOG_FILE, mode="a", index=False, header=False)
163
- else:
164
- df.to_csv(LOG_FILE, index=False)
165
-
166
- all_logs = pd.read_csv(LOG_FILE)
167
- tmp_dir = tempfile.mkdtemp()
168
- tmp_path = os.path.join(tmp_dir, "data.parquet")
169
- all_logs.to_parquet(tmp_path)
170
- dataset = Dataset.from_parquet(tmp_path)
171
- dataset.push_to_hub(DATASET_REPO, token=HF_TOKEN)
172
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- # ======================================================
175
- # Start
176
- # ======================================================
 
 
 
 
 
177
 
178
- def start_test(student_id, level_input):
179
- global current_user_id, current_level, used_passages
180
- used_passages = set()
181
 
182
- action = "start_pushed"
183
- now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
184
 
185
  entry = {
186
- "user_id": student_id,
187
- "assigned_level": current_level,
188
  "passage_id": None,
189
  "original_level": None,
190
  "action_time": now,
@@ -193,29 +250,18 @@ def start_test(student_id, level_input):
193
  }
194
  save_log(entry)
195
 
196
- if not student_id or str(student_id).strip() == "":
197
- return (
198
- "", "", json.dumps([]), 0, "",
199
- 0, "", None, None,
200
- gr.update(interactive=False, visible=False),
201
- gr.update(interactive=False, visible=True),
202
- gr.update(interactive=False, visible=False)
203
- )
204
-
205
- current_user_id = str(student_id).strip()
206
- current_level = int(level_input)
207
-
208
- pid, text, orig_lev = get_new_passage_random()
209
  if text is None:
210
  return (
211
  "教材が見つかりません", "", json.dumps([]), 0, "",
212
  0, "", None, None,
213
  gr.update(interactive=False, visible=False),
214
  gr.update(interactive=False, visible=False),
215
- gr.update(interactive=False, visible=False)
 
216
  )
217
 
218
- rewritten = rewrite_level(text, current_level)
219
  pages = split_pages(rewritten)
220
  total = len(pages)
221
 
@@ -229,18 +275,25 @@ def start_test(student_id, level_input):
229
  finish_upd = gr.update(interactive=False, visible=False)
230
 
231
  page_num = 1
232
- now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
233
 
234
- entry = {
235
- "user_id": current_user_id,
236
- "assigned_level": current_level,
237
  "passage_id": pid,
238
  "original_level": orig_lev,
239
- "action_time": now,
240
  "action_type": f"page_displayed_{page_num}",
241
  "page_text": pages[0]
242
  }
243
- save_log(entry)
 
 
 
 
 
 
 
244
 
245
  return (
246
  pages[0],
@@ -250,23 +303,25 @@ def start_test(student_id, level_input):
250
  total,
251
  pid,
252
  orig_lev,
253
- current_level,
254
  prev_upd,
255
  next_upd,
256
- finish_upd
 
257
  )
258
 
259
-
260
  # ======================================================
261
- # Next / Prev / Finish(以下は元コードのまま)
262
  # ======================================================
263
 
264
- def next_page(pages_json, current_page, total_pages, pid, orig_lev):
265
- now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
 
266
 
 
267
  entry = {
268
- "user_id": current_user_id,
269
- "assigned_level": current_level,
270
  "passage_id": pid,
271
  "original_level": orig_lev,
272
  "action_time": now,
@@ -280,14 +335,15 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev):
280
  return ("", "", json.dumps([]), 0,
281
  gr.update(interactive=False, visible=False),
282
  gr.update(interactive=False, visible=False),
283
- gr.update(interactive=False, visible=False))
 
284
 
285
  new_page = min(current_page + 1, total_pages - 1)
286
 
287
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
288
  entry2 = {
289
- "user_id": current_user_id,
290
- "assigned_level": current_level,
291
  "passage_id": pid,
292
  "original_level": orig_lev,
293
  "action_time": now2,
@@ -304,7 +360,8 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev):
304
  new_page,
305
  gr.update(interactive=True, visible=True),
306
  gr.update(interactive=False, visible=False),
307
- gr.update(interactive=True, visible=True)
 
308
  )
309
 
310
  return (
@@ -314,16 +371,18 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev):
314
  new_page,
315
  gr.update(interactive=(new_page > 0), visible=(new_page > 0)),
316
  gr.update(interactive=True, visible=True),
317
- gr.update(interactive=False, visible=False)
 
318
  )
319
 
 
 
 
320
 
321
- def prev_page(pages_json, current_page, total_pages, pid, orig_lev):
322
  now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
323
-
324
  entry = {
325
- "user_id": current_user_id,
326
- "assigned_level": current_level,
327
  "passage_id": pid,
328
  "original_level": orig_lev,
329
  "action_time": now,
@@ -337,7 +396,8 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev):
337
  return ("", "", json.dumps([]), 0,
338
  gr.update(interactive=False, visible=False),
339
  gr.update(interactive=False, visible=False),
340
- gr.update(interactive=False, visible=False))
 
341
 
342
  new_page = max(current_page - 1, 0)
343
 
@@ -348,8 +408,8 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev):
348
 
349
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
350
  entry2 = {
351
- "user_id": current_user_id,
352
- "assigned_level": current_level,
353
  "passage_id": pid,
354
  "original_level": orig_lev,
355
  "action_time": now2,
@@ -365,17 +425,21 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev):
365
  new_page,
366
  prev_upd,
367
  next_upd,
368
- finish_upd
 
369
  )
370
 
 
 
 
 
371
 
372
- def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
373
  pages = json.loads(pages_json)
374
  now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
375
 
376
  entry = {
377
- "user_id": current_user_id,
378
- "assigned_level": current_level,
379
  "passage_id": pid,
380
  "original_level": orig_lev,
381
  "action_time": now,
@@ -384,17 +448,18 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
384
  }
385
  save_log(entry)
386
 
387
- new_pid, new_text, new_orig_lev = get_new_passage_random()
388
  if new_text is None:
389
  return (
390
  "教材がありません", "", json.dumps([]), 0, "",
391
  0, "", None, None,
392
  gr.update(interactive=False, visible=False),
393
  gr.update(interactive=False, visible=False),
394
- gr.update(interactive=False, visible=False)
 
395
  )
396
 
397
- rewritten = rewrite_level(new_text, current_level)
398
  new_pages = split_pages(rewritten)
399
  total = len(new_pages)
400
 
@@ -409,8 +474,8 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
409
 
410
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
411
  entry2 = {
412
- "user_id": current_user_id,
413
- "assigned_level": current_level,
414
  "passage_id": new_pid,
415
  "original_level": new_orig_lev,
416
  "action_time": now2,
@@ -419,6 +484,12 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
419
  }
420
  save_log(entry2)
421
 
 
 
 
 
 
 
422
  return (
423
  new_pages[0],
424
  f"1 / {total}",
@@ -427,13 +498,13 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
427
  total,
428
  new_pid,
429
  new_orig_lev,
430
- current_level,
431
  prev_upd,
432
  next_upd,
433
- finish_upd
 
434
  )
435
 
436
-
437
  # ======================================================
438
  # UI
439
  # ======================================================
@@ -507,10 +578,12 @@ custom_css = """
507
  }
508
  """
509
 
510
-
511
  with gr.Blocks(css=custom_css) as demo:
512
  gr.Markdown("# 📚 Reading Exercise")
513
 
 
 
 
514
  student_id_input = gr.Textbox(label="学生番号(必須)")
515
  level_input = gr.Dropdown(
516
  choices=[1,2,3,4,5],
@@ -544,13 +617,14 @@ with gr.Blocks(css=custom_css) as demo:
544
 
545
  start_btn.click(
546
  fn=start_test,
547
- inputs=[student_id_input, level_input],
548
  outputs=[
549
  text_display, page_display,
550
  hidden_pages, hidden_page_index,
551
  hidden_total_pages, hidden_passage_id,
552
  hidden_orig_lev, hidden_assigned_lev,
553
- prev_btn, next_btn, finish_btn
 
554
  ]
555
  )
556
 
@@ -559,12 +633,13 @@ with gr.Blocks(css=custom_css) as demo:
559
  inputs=[
560
  hidden_pages, hidden_page_index,
561
  hidden_total_pages, hidden_passage_id,
562
- hidden_orig_lev
563
  ],
564
  outputs=[
565
  text_display, page_display,
566
  hidden_pages, hidden_page_index,
567
- prev_btn, next_btn, finish_btn
 
568
  ]
569
  )
570
 
@@ -573,40 +648,47 @@ with gr.Blocks(css=custom_css) as demo:
573
  inputs=[
574
  hidden_pages, hidden_page_index,
575
  hidden_total_pages, hidden_passage_id,
576
- hidden_orig_lev
577
  ],
578
  outputs=[
579
  text_display, page_display,
580
  hidden_pages, hidden_page_index,
581
- prev_btn, next_btn, finish_btn
 
582
  ]
583
  )
584
 
585
  finish_btn.click(
586
- fn=lambda p, i, pid, o: finish_or_retire(p, i, pid, o, "finished"),
587
- inputs=[hidden_pages, hidden_page_index, hidden_passage_id, hidden_orig_lev],
588
  outputs=[
589
  text_display, page_display,
590
  hidden_pages, hidden_page_index,
591
  hidden_total_pages, hidden_passage_id,
592
  hidden_orig_lev, hidden_assigned_lev,
593
- prev_btn, next_btn, finish_btn
 
594
  ]
595
  )
596
 
597
  retire_btn.click(
598
- fn=lambda p, i, pid, o: finish_or_retire(p, i, pid, o, "retire"),
599
  inputs=[
600
  hidden_pages, hidden_page_index,
601
- hidden_passage_id, hidden_orig_lev
 
602
  ],
603
  outputs=[
604
  text_display, page_display,
605
  hidden_pages, hidden_page_index,
606
  hidden_total_pages, hidden_passage_id,
607
  hidden_orig_lev, hidden_assigned_lev,
608
- prev_btn, next_btn, finish_btn
 
609
  ]
610
  )
611
 
 
 
 
612
  demo.launch()
 
4
  from datetime import datetime, timedelta
5
  import pandas as pd
6
  import time, os, random, tempfile, json, glob
7
+ import re
8
+ import threading
9
 
10
  # --- API / HF 設定 ---
11
  API_KEY = os.getenv("API_KEY")
12
  BASE_URL = "https://openrouter.ai/api/v1"
13
  HF_TOKEN = os.getenv("HF_TOKEN")
14
  DATASET_REPO = "Toya0421/reading_exercise_logging"
15
+
16
+ # ログは Spaces の永続ストレージ(= Files)へ
17
+ LOG_FILE = "/data/log.csv"
18
 
19
  client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
20
 
21
  # --- passage_information.xlsx 読み込み (Text# と flesch_score 使用) ---
22
  passage_info_df = pd.read_excel("passage_information.xlsx")
23
 
24
+ # ======================================================
25
+ # () 重い処理の同時実行制限:rewrite API を最大N並列に制限
26
+ # ======================================================
27
+ REWRITE_CONCURRENCY = int(os.getenv("REWRITE_CONCURRENCY", "3")) # 5,6人想定なら 2〜3 推奨
28
+ _rewrite_sem = threading.Semaphore(REWRITE_CONCURRENCY)
29
+
30
+ # ======================================================
31
+ # (①) ログ:毎アクションはFiles(/data/log.csv)へ追記のみ
32
+ # + 5分ごとに dataset.push_to_hub だけ実行
33
+ # ======================================================
34
+ _log_lock = threading.Lock()
35
+ PUSH_INTERVAL_SEC = int(os.getenv("PUSH_INTERVAL_SEC", "300")) # 5分
36
+
37
+ def save_log(entry):
38
+ """
39
+ 毎アクション:/data/log.csv に追記するだけ(軽量)
40
+ """
41
+ os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
42
+ df = pd.DataFrame([entry])
43
+
44
+ with _log_lock:
45
+ if os.path.exists(LOG_FILE):
46
+ df.to_csv(LOG_FILE, mode="a", index=False, header=False, encoding="utf-8")
47
+ else:
48
+ df.to_csv(LOG_FILE, index=False, header=True, encoding="utf-8")
49
+
50
+ def _push_logs_to_hub_once():
51
+ """
52
+ 5分ごと:/data/log.csv を読み込み、parquet化して Dataset として Hub にpush
53
+ """
54
+ if not HF_TOKEN:
55
+ print("[WARN] HF_TOKEN is not set. Skip push.")
56
+ return
57
+ if not os.path.exists(LOG_FILE):
58
+ return
59
+
60
+ # CSV追記と競合しないようロック
61
+ with _log_lock:
62
+ all_logs = pd.read_csv(LOG_FILE)
63
 
64
+ tmp_dir = tempfile.mkdtemp()
65
+ tmp_path = os.path.join(tmp_dir, "data.parquet")
66
+ all_logs.to_parquet(tmp_path)
67
+
68
+ dataset = Dataset.from_parquet(tmp_path)
69
+ dataset.push_to_hub(DATASET_REPO, token=HF_TOKEN)
70
+
71
+ def _start_periodic_push_thread():
72
+ """
73
+ アプリ起動時に1回だけ呼ぶ。以後はdaemonスレッドで5分ごとpush。
74
+ """
75
+ def _worker():
76
+ while True:
77
+ time.sleep(PUSH_INTERVAL_SEC)
78
+ try:
79
+ _push_logs_to_hub_once()
80
+ except Exception as e:
81
+ # push失敗しても全体を落とさない
82
+ print(f"[WARN] periodic push failed: {e}")
83
+
84
+ t = threading.Thread(target=_worker, daemon=True)
85
+ t.start()
86
+
87
+ # 起動(1回だけ)
88
+ _start_periodic_push_thread()
89
 
90
  # ======================================================
91
  # 新しい教材管理:passages フォルダからランダム選択
92
+ # ※ used_passages は session_state に保持(グローバル禁止)
93
  # ======================================================
94
 
95
  def load_passage_file(text_id):
 
102
  with open(path, "r", encoding="utf-8") as f:
103
  return f.read()
104
 
105
+ def get_new_passage_random(used_passages_set):
106
  """
107
  passages フォルダからランダムに教材を選び(pg◯.txt)、
108
  passage_information.xlsx の Text# の flesch_score を original_level として返す。
109
  """
 
 
 
110
  files = glob.glob("passages/pg*.txt")
111
  if not files:
112
+ return None, None, None, used_passages_set
113
 
 
114
  all_ids = []
115
  for f in files:
116
  name = os.path.basename(f)
 
118
  if num.isdigit():
119
  all_ids.append(int(num))
120
 
121
+ available = [pid for pid in all_ids if pid not in used_passages_set]
 
122
  if not available:
123
+ used_passages_set = set()
124
  available = list(all_ids)
125
 
 
126
  text_id = random.choice(available)
127
+ used_passages_set.add(text_id)
128
 
 
129
  text = load_passage_file(text_id)
130
  if text is None:
131
+ return None, None, None, used_passages_set
132
 
 
133
  row = passage_info_df[passage_info_df["Text#"] == text_id]
134
  if len(row) == 0:
135
  orig_level = None
136
  else:
137
  orig_level = row.iloc[0]["flesch_score"]
138
 
139
+ return text_id, text, orig_level, used_passages_set
 
140
 
141
  # ======================================================
142
+ # Rewrite(同時実行制限付き)
143
  # ======================================================
144
 
145
  def rewrite_level(text, target_level):
 
164
  {text}
165
  """
166
 
167
+ # (③) rewrite API 同時実行制限
168
+ with _rewrite_sem:
169
+ resp = client.chat.completions.create(
170
+ model="google/gemini-2.5-flash",
171
+ messages=[{"role": "user", "content": prompt}],
172
+ temperature=0.4,
173
+ max_tokens=5000
174
+ )
175
  return resp.choices[0].message.content.strip()
176
 
 
 
 
177
  def split_pages(text, max_words=300):
178
  """
179
  文単位でページを分割する。
180
  - 文の途中でページを分割しない
181
  - max_words の上限を超えないようにする
182
  """
 
183
  sentences = re.split(r'(?<=[.!?])\s+', text.strip())
184
  pages = []
185
  current_page = []
 
189
  words = sentence.split()
190
  sentence_len = len(words)
191
 
 
192
  if current_word_count + sentence_len > max_words:
193
  if current_page:
194
  pages.append(" ".join(current_page))
 
198
  current_page.append(sentence)
199
  current_word_count += sentence_len
200
 
 
201
  if current_page:
202
  pages.append(" ".join(current_page))
203
 
204
  return pages or [text]
205
 
 
206
  # ======================================================
207
+ # Start(session_stateでユーザー状態管理)
208
  # ======================================================
209
 
210
+ def start_test(student_id, level_input, session_state):
211
+ action = "start_pushed"
212
+ now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
 
 
 
 
 
 
 
 
 
 
213
 
214
+ # student_id 未入力でも「押した」ログは残す(元意図に近い)
215
+ if not student_id or str(student_id).strip() == "":
216
+ entry = {
217
+ "user_id": None,
218
+ "assigned_level": None,
219
+ "passage_id": None,
220
+ "original_level": None,
221
+ "action_time": now,
222
+ "action_type": action,
223
+ "page_text": None
224
+ }
225
+ save_log(entry)
226
 
227
+ return (
228
+ "", "", json.dumps([]), 0, "",
229
+ 0, "", None, None,
230
+ gr.update(interactive=False, visible=False),
231
+ gr.update(interactive=False, visible=True),
232
+ gr.update(interactive=False, visible=False),
233
+ session_state
234
+ )
235
 
236
+ user_id = str(student_id).strip()
237
+ level = int(level_input)
 
238
 
239
+ # startでリセット(元コード踏襲)
240
+ used_passages_set = set()
241
 
242
  entry = {
243
+ "user_id": user_id,
244
+ "assigned_level": level,
245
  "passage_id": None,
246
  "original_level": None,
247
  "action_time": now,
 
250
  }
251
  save_log(entry)
252
 
253
+ pid, text, orig_lev, used_passages_set = get_new_passage_random(used_passages_set)
 
 
 
 
 
 
 
 
 
 
 
 
254
  if text is None:
255
  return (
256
  "教材が見つかりません", "", json.dumps([]), 0, "",
257
  0, "", None, None,
258
  gr.update(interactive=False, visible=False),
259
  gr.update(interactive=False, visible=False),
260
+ gr.update(interactive=False, visible=False),
261
+ session_state
262
  )
263
 
264
+ rewritten = rewrite_level(text, level)
265
  pages = split_pages(rewritten)
266
  total = len(pages)
267
 
 
275
  finish_upd = gr.update(interactive=False, visible=False)
276
 
277
  page_num = 1
278
+ now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
279
 
280
+ entry2 = {
281
+ "user_id": user_id,
282
+ "assigned_level": level,
283
  "passage_id": pid,
284
  "original_level": orig_lev,
285
+ "action_time": now2,
286
  "action_type": f"page_displayed_{page_num}",
287
  "page_text": pages[0]
288
  }
289
+ save_log(entry2)
290
+
291
+ # session_state 更新
292
+ session_state = {
293
+ "user_id": user_id,
294
+ "level": level,
295
+ "used_passages": list(used_passages_set)
296
+ }
297
 
298
  return (
299
  pages[0],
 
303
  total,
304
  pid,
305
  orig_lev,
306
+ level,
307
  prev_upd,
308
  next_upd,
309
+ finish_upd,
310
+ session_state
311
  )
312
 
 
313
  # ======================================================
314
+ # Next / Prev / Finish(元コードのままの構造 + state参照
315
  # ======================================================
316
 
317
+ def next_page(pages_json, current_page, total_pages, pid, orig_lev, session_state):
318
+ user_id = session_state.get("user_id")
319
+ level = session_state.get("level")
320
 
321
+ now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
322
  entry = {
323
+ "user_id": user_id,
324
+ "assigned_level": level,
325
  "passage_id": pid,
326
  "original_level": orig_lev,
327
  "action_time": now,
 
335
  return ("", "", json.dumps([]), 0,
336
  gr.update(interactive=False, visible=False),
337
  gr.update(interactive=False, visible=False),
338
+ gr.update(interactive=False, visible=False),
339
+ session_state)
340
 
341
  new_page = min(current_page + 1, total_pages - 1)
342
 
343
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
344
  entry2 = {
345
+ "user_id": user_id,
346
+ "assigned_level": level,
347
  "passage_id": pid,
348
  "original_level": orig_lev,
349
  "action_time": now2,
 
360
  new_page,
361
  gr.update(interactive=True, visible=True),
362
  gr.update(interactive=False, visible=False),
363
+ gr.update(interactive=True, visible=True),
364
+ session_state
365
  )
366
 
367
  return (
 
371
  new_page,
372
  gr.update(interactive=(new_page > 0), visible=(new_page > 0)),
373
  gr.update(interactive=True, visible=True),
374
+ gr.update(interactive=False, visible=False),
375
+ session_state
376
  )
377
 
378
+ def prev_page(pages_json, current_page, total_pages, pid, orig_lev, session_state):
379
+ user_id = session_state.get("user_id")
380
+ level = session_state.get("level")
381
 
 
382
  now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
 
383
  entry = {
384
+ "user_id": user_id,
385
+ "assigned_level": level,
386
  "passage_id": pid,
387
  "original_level": orig_lev,
388
  "action_time": now,
 
396
  return ("", "", json.dumps([]), 0,
397
  gr.update(interactive=False, visible=False),
398
  gr.update(interactive=False, visible=False),
399
+ gr.update(interactive=False, visible=False),
400
+ session_state)
401
 
402
  new_page = max(current_page - 1, 0)
403
 
 
408
 
409
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
410
  entry2 = {
411
+ "user_id": user_id,
412
+ "assigned_level": level,
413
  "passage_id": pid,
414
  "original_level": orig_lev,
415
  "action_time": now2,
 
425
  new_page,
426
  prev_upd,
427
  next_upd,
428
+ finish_upd,
429
+ session_state
430
  )
431
 
432
+ def finish_or_retire(pages_json, current_page, pid, orig_lev, action, session_state):
433
+ user_id = session_state.get("user_id")
434
+ level = session_state.get("level")
435
+ used_passages_set = set(session_state.get("used_passages", []))
436
 
 
437
  pages = json.loads(pages_json)
438
  now = (datetime.utcnow() + timedelta(hours=9)).isoformat()
439
 
440
  entry = {
441
+ "user_id": user_id,
442
+ "assigned_level": level,
443
  "passage_id": pid,
444
  "original_level": orig_lev,
445
  "action_time": now,
 
448
  }
449
  save_log(entry)
450
 
451
+ new_pid, new_text, new_orig_lev, used_passages_set = get_new_passage_random(used_passages_set)
452
  if new_text is None:
453
  return (
454
  "教材がありません", "", json.dumps([]), 0, "",
455
  0, "", None, None,
456
  gr.update(interactive=False, visible=False),
457
  gr.update(interactive=False, visible=False),
458
+ gr.update(interactive=False, visible=False),
459
+ session_state
460
  )
461
 
462
+ rewritten = rewrite_level(new_text, level)
463
  new_pages = split_pages(rewritten)
464
  total = len(new_pages)
465
 
 
474
 
475
  now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat()
476
  entry2 = {
477
+ "user_id": user_id,
478
+ "assigned_level": level,
479
  "passage_id": new_pid,
480
  "original_level": new_orig_lev,
481
  "action_time": now2,
 
484
  }
485
  save_log(entry2)
486
 
487
+ session_state = {
488
+ "user_id": user_id,
489
+ "level": level,
490
+ "used_passages": list(used_passages_set)
491
+ }
492
+
493
  return (
494
  new_pages[0],
495
  f"1 / {total}",
 
498
  total,
499
  new_pid,
500
  new_orig_lev,
501
+ level,
502
  prev_upd,
503
  next_upd,
504
+ finish_upd,
505
+ session_state
506
  )
507
 
 
508
  # ======================================================
509
  # UI
510
  # ======================================================
 
578
  }
579
  """
580
 
 
581
  with gr.Blocks(css=custom_css) as demo:
582
  gr.Markdown("# 📚 Reading Exercise")
583
 
584
+ # セッションごとの状態(グローバル禁止)
585
+ session_state = gr.State({"user_id": None, "level": None, "used_passages": []})
586
+
587
  student_id_input = gr.Textbox(label="学生番号(必須)")
588
  level_input = gr.Dropdown(
589
  choices=[1,2,3,4,5],
 
617
 
618
  start_btn.click(
619
  fn=start_test,
620
+ inputs=[student_id_input, level_input, session_state],
621
  outputs=[
622
  text_display, page_display,
623
  hidden_pages, hidden_page_index,
624
  hidden_total_pages, hidden_passage_id,
625
  hidden_orig_lev, hidden_assigned_lev,
626
+ prev_btn, next_btn, finish_btn,
627
+ session_state
628
  ]
629
  )
630
 
 
633
  inputs=[
634
  hidden_pages, hidden_page_index,
635
  hidden_total_pages, hidden_passage_id,
636
+ hidden_orig_lev, session_state
637
  ],
638
  outputs=[
639
  text_display, page_display,
640
  hidden_pages, hidden_page_index,
641
+ prev_btn, next_btn, finish_btn,
642
+ session_state
643
  ]
644
  )
645
 
 
648
  inputs=[
649
  hidden_pages, hidden_page_index,
650
  hidden_total_pages, hidden_passage_id,
651
+ hidden_orig_lev, session_state
652
  ],
653
  outputs=[
654
  text_display, page_display,
655
  hidden_pages, hidden_page_index,
656
+ prev_btn, next_btn, finish_btn,
657
+ session_state
658
  ]
659
  )
660
 
661
  finish_btn.click(
662
+ fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "finished", st),
663
+ inputs=[hidden_pages, hidden_page_index, hidden_passage_id, hidden_orig_lev, session_state],
664
  outputs=[
665
  text_display, page_display,
666
  hidden_pages, hidden_page_index,
667
  hidden_total_pages, hidden_passage_id,
668
  hidden_orig_lev, hidden_assigned_lev,
669
+ prev_btn, next_btn, finish_btn,
670
+ session_state
671
  ]
672
  )
673
 
674
  retire_btn.click(
675
+ fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "retire", st),
676
  inputs=[
677
  hidden_pages, hidden_page_index,
678
+ hidden_passage_id, hidden_orig_lev,
679
+ session_state
680
  ],
681
  outputs=[
682
  text_display, page_display,
683
  hidden_pages, hidden_page_index,
684
  hidden_total_pages, hidden_passage_id,
685
  hidden_orig_lev, hidden_assigned_lev,
686
+ prev_btn, next_btn, finish_btn,
687
+ session_state
688
  ]
689
  )
690
 
691
+ # Gradio側のキューもON(HF環境差分で壊れにくい設定に寄せる)
692
+ demo.queue(max_size=64)
693
+
694
  demo.launch()