Toya0421 commited on
Commit
80bf4a9
·
verified ·
1 Parent(s): 150bf9a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +210 -96
app.py CHANGED
@@ -1,25 +1,50 @@
1
- import gradio as gr
2
- from openai import OpenAI
 
 
 
 
 
 
 
 
3
  from datetime import datetime, timedelta
 
 
4
  import pandas as pd
5
- import os, random, json, glob, re, sqlite3, threading, time
 
6
 
7
- # --- API / 設定 ---
 
 
8
  API_KEY = os.getenv("API_KEY")
9
- BASE_URL = "https://openrouter.ai/api/v1"
10
- client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
 
 
 
 
 
 
 
 
 
11
 
12
  LOG_DB = "reading_logs.sqlite"
13
  CACHE_DIR = "rewrite_cache"
14
  os.makedirs(CACHE_DIR, exist_ok=True)
15
 
16
- # --- passage_information.xlsx ---
 
 
17
  passage_info_df = pd.read_excel("passage_information.xlsx")
18
 
19
- # ======================================================
20
- # DB(SQLite): 速い・同時アクセスに強い(WAL
21
- # ======================================================
22
  _db_lock = threading.Lock()
 
23
 
24
  def init_db():
25
  with _db_lock:
@@ -42,8 +67,11 @@ def init_db():
42
 
43
  init_db()
44
 
 
 
 
45
  def save_log(entry: dict):
46
- # 1イベント=1INSERT(軽
47
  with _db_lock:
48
  conn = sqlite3.connect(LOG_DB, check_same_thread=False)
49
  conn.execute("PRAGMA journal_mode=WAL;")
@@ -62,16 +90,54 @@ def save_log(entry: dict):
62
  conn.commit()
63
  conn.close()
64
 
65
- # ======================================================
66
- # passages 管理
67
- # ======================================================
68
- def load_passage_file(text_id: int):
69
- path = f"passages/pg{text_id}.txt"
70
- if not os.path.exists(path):
71
- return None
72
- with open(path, "r", encoding="utf-8") as f:
73
- return f.read()
74
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def list_passage_ids():
76
  files = glob.glob("passages/pg*.txt")
77
  ids = []
@@ -84,38 +150,49 @@ def list_passage_ids():
84
 
85
  ALL_PASSAGE_IDS = list_passage_ids()
86
 
87
- def get_new_passage_random(used_passages: set):
 
 
 
 
 
 
 
 
 
88
  if not ALL_PASSAGE_IDS:
89
- return None, None, None, used_passages
90
 
91
- available = [pid for pid in ALL_PASSAGE_IDS if pid not in used_passages]
92
  if not available:
93
- used_passages.clear()
94
  available = list(ALL_PASSAGE_IDS)
95
 
96
- text_id = random.choice(available)
97
- used_passages.add(text_id)
98
 
99
- text = load_passage_file(text_id)
100
  if text is None:
101
- return None, None, None, used_passages
102
 
103
- row = passage_info_df[passage_info_df["Text#"] == text_id]
104
  orig_level = None if len(row) == 0 else row.iloc[0]["flesch_score"]
105
- return text_id, text, orig_level, used_passages
106
 
107
- # ======================================================
108
- # Rewrite(キャッシュ付き)
109
- # ======================================================
110
  def rewrite_cache_path(passage_id: int, level: int):
111
  return os.path.join(CACHE_DIR, f"pg{passage_id}_lv{level}.json")
112
 
113
  def rewrite_level(text: str, target_level: int, passage_id: int):
114
- # ディスクキャッシュ
115
  cpath = rewrite_cache_path(passage_id, target_level)
116
  if os.path.exists(cpath):
117
- with open(cpath, "r", encoding="utf-8") as f:
118
- return json.load(f)["rewritten"]
 
 
 
119
 
120
  level_to_flesch = {1: 90, 2: 70, 3: 55, 4: 40, 5: 25}
121
  target_flesch = level_to_flesch[int(target_level)]
@@ -139,16 +216,17 @@ excluding the title, author name, source information, chapter number, annotation
139
  )
140
  rewritten = resp.choices[0].message.content.strip()
141
 
142
- # キャッシュ保存
143
- with open(cpath, "w", encoding="utf-8") as f:
144
- json.dump({"rewritten": rewritten}, f, ensure_ascii=False)
 
 
145
 
146
  return rewritten
147
 
148
  def split_pages(text, max_words=300):
149
  sentences = re.split(r'(?<=[.!?])\s+', text.strip())
150
  pages, current, wc = [], [], 0
151
-
152
  for s in sentences:
153
  w = s.split()
154
  if wc + len(w) > max_words and current:
@@ -157,32 +235,21 @@ def split_pages(text, max_words=300):
157
  else:
158
  current.append(s)
159
  wc += len(w)
160
-
161
  if current:
162
  pages.append(" ".join(current))
163
-
164
  return pages or [text]
165
 
166
- def now_jst_iso():
167
- return (datetime.utcnow() + timedelta(hours=9)).isoformat()
168
-
169
- # ======================================================
170
- # ハンドラ(セッション状態: gr.State)
171
- # ======================================================
172
- # state = {
173
- # "user_id": str,
174
- # "level": int,
175
- # "used_passages": set(int),
176
- # }
177
-
178
  def start_test(student_id, level_input, state):
179
  if state is None:
180
- state = {"user_id": None, "level": None, "used_passages": set()}
181
 
182
- # 必須チェック
183
  if not student_id or str(student_id).strip() == "":
184
  return (
185
- "", "", json.dumps([]), 0, 0, "", "", "",
186
  gr.update(interactive=False, visible=False),
187
  gr.update(interactive=False, visible=False),
188
  gr.update(interactive=False, visible=False),
@@ -191,9 +258,8 @@ def start_test(student_id, level_input, state):
191
 
192
  state["user_id"] = str(student_id).strip()
193
  state["level"] = int(level_input)
194
- state["used_passages"] = set()
195
 
196
- # startログ(軽い)
197
  save_log({
198
  "user_id": state["user_id"],
199
  "assigned_level": state["level"],
@@ -204,10 +270,12 @@ def start_test(student_id, level_input, state):
204
  "page_text": None
205
  })
206
 
207
- pid, text, orig_lev, state["used_passages"] = get_new_passage_random(state["used_passages"])
 
 
208
  if text is None:
209
  return (
210
- "教材が見つかりません", "", json.dumps([]), 0, 0, "", "", "",
211
  gr.update(interactive=False, visible=False),
212
  gr.update(interactive=False, visible=False),
213
  gr.update(interactive=False, visible=False),
@@ -220,7 +288,7 @@ def start_test(student_id, level_input, state):
220
 
221
  prev_upd = gr.update(interactive=False, visible=False)
222
  next_upd = gr.update(interactive=(total > 1), visible=(total > 1))
223
- finish_upd = gr.update(interactive=(total == 1), visible=True if total == 1 else False)
224
 
225
  save_log({
226
  "user_id": state["user_id"],
@@ -239,7 +307,7 @@ def start_test(student_id, level_input, state):
239
  0,
240
  total,
241
  str(pid),
242
- str(orig_lev) if orig_lev is not None else "",
243
  str(state["level"]),
244
  prev_upd, next_upd, finish_upd,
245
  state
@@ -248,15 +316,15 @@ def start_test(student_id, level_input, state):
248
  def next_page(pages_json, current_page, total_pages, pid, orig_lev, state):
249
  pages = json.loads(pages_json) if pages_json else []
250
  if not pages:
251
- return ("", "", json.dumps([]), 0,
252
  gr.update(interactive=False, visible=False),
253
  gr.update(interactive=False, visible=False),
254
  gr.update(interactive=False, visible=False),
255
  state)
256
 
257
  save_log({
258
- "user_id": state["user_id"],
259
- "assigned_level": state["level"],
260
  "passage_id": int(pid),
261
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
262
  "action_time": now_jst_iso(),
@@ -264,11 +332,13 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev, state):
264
  "page_text": None
265
  })
266
 
267
- new_page = min(int(current_page) + 1, int(total_pages) - 1)
 
 
268
 
269
  save_log({
270
- "user_id": state["user_id"],
271
- "assigned_level": state["level"],
272
  "passage_id": int(pid),
273
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
274
  "action_time": now_jst_iso(),
@@ -277,7 +347,7 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev, state):
277
  })
278
 
279
  prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0))
280
- next_visible = (new_page < int(total_pages) - 1)
281
  next_upd = gr.update(interactive=next_visible, visible=next_visible)
282
  finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible))
283
 
@@ -293,15 +363,15 @@ def next_page(pages_json, current_page, total_pages, pid, orig_lev, state):
293
  def prev_page(pages_json, current_page, total_pages, pid, orig_lev, state):
294
  pages = json.loads(pages_json) if pages_json else []
295
  if not pages:
296
- return ("", "", json.dumps([]), 0,
297
  gr.update(interactive=False, visible=False),
298
  gr.update(interactive=False, visible=False),
299
  gr.update(interactive=False, visible=False),
300
  state)
301
 
302
  save_log({
303
- "user_id": state["user_id"],
304
- "assigned_level": state["level"],
305
  "passage_id": int(pid),
306
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
307
  "action_time": now_jst_iso(),
@@ -309,11 +379,13 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev, state):
309
  "page_text": None
310
  })
311
 
312
- new_page = max(int(current_page) - 1, 0)
 
 
313
 
314
  save_log({
315
- "user_id": state["user_id"],
316
- "assigned_level": state["level"],
317
  "passage_id": int(pid),
318
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
319
  "action_time": now_jst_iso(),
@@ -322,7 +394,7 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev, state):
322
  })
323
 
324
  prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0))
325
- next_visible = (new_page < int(total_pages) - 1)
326
  next_upd = gr.update(interactive=next_visible, visible=next_visible)
327
  finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible))
328
 
@@ -337,8 +409,8 @@ def prev_page(pages_json, current_page, total_pages, pid, orig_lev, state):
337
 
338
  def finish_or_retire(pages_json, current_page, pid, orig_lev, action, state):
339
  save_log({
340
- "user_id": state["user_id"],
341
- "assigned_level": state["level"],
342
  "passage_id": int(pid),
343
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
344
  "action_time": now_jst_iso(),
@@ -346,10 +418,12 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action, state):
346
  "page_text": None
347
  })
348
 
349
- new_pid, new_text, new_orig_lev, state["used_passages"] = get_new_passage_random(state["used_passages"])
 
 
350
  if new_text is None:
351
  return (
352
- "教材がありません", "", json.dumps([]), 0, 0, "", "", "",
353
  gr.update(interactive=False, visible=False),
354
  gr.update(interactive=False, visible=False),
355
  gr.update(interactive=False, visible=False),
@@ -362,11 +436,11 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action, state):
362
 
363
  prev_upd = gr.update(interactive=False, visible=False)
364
  next_upd = gr.update(interactive=(total > 1), visible=(total > 1))
365
- finish_upd = gr.update(interactive=(total == 1), visible=True if total == 1 else False)
366
 
367
  save_log({
368
- "user_id": state["user_id"],
369
- "assigned_level": state["level"],
370
  "passage_id": new_pid,
371
  "original_level": new_orig_lev,
372
  "action_time": now_jst_iso(),
@@ -381,16 +455,57 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action, state):
381
  0,
382
  total,
383
  str(new_pid),
384
- str(new_orig_lev) if new_orig_lev is not None else "",
385
  str(state["level"]),
386
  prev_upd, next_upd, finish_upd,
387
  state
388
  )
389
 
390
- # ======================================================
391
- # UI
392
- # ======================================================
393
- custom_css = """/* ここはあなたのCSSをそのまま貼ってOK */"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  with gr.Blocks(css=custom_css) as demo:
396
  gr.Markdown("# 📚 Reading Exercise")
@@ -399,7 +514,7 @@ with gr.Blocks(css=custom_css) as demo:
399
  level_input = gr.Dropdown(choices=[1,2,3,4,5], label="Reading Level", value=3)
400
  start_btn = gr.Button("スタート")
401
 
402
- text_display = gr.Textbox(label="教材", lines=18, interactive=False)
403
  page_display = gr.Textbox(label="進行状況", lines=1, interactive=False)
404
 
405
  hidden_pages = gr.Textbox(visible=False)
@@ -416,7 +531,7 @@ with gr.Blocks(css=custom_css) as demo:
416
 
417
  retire_btn = gr.Button("リタイア")
418
 
419
- state = gr.State({"user_id": None, "level": None, "used_passages": set()})
420
 
421
  start_btn.click(
422
  fn=start_test,
@@ -469,7 +584,6 @@ with gr.Blocks(css=custom_css) as demo:
469
  ],
470
  )
471
 
472
- # ★同時実行を制御(重要)
473
- demo.queue(concurrency_count=8, max_size=64)
474
-
475
- demo.launch()
 
1
+ import os
2
+ import re
3
+ import json
4
+ import glob
5
+ import time
6
+ import random
7
+ import sqlite3
8
+ import threading
9
+ import tempfile
10
+ import inspect
11
  from datetime import datetime, timedelta
12
+
13
+ import gradio as gr
14
  import pandas as pd
15
+ from openai import OpenAI
16
+ from datasets import Dataset
17
 
18
+ # =========================
19
+ # Config
20
+ # =========================
21
  API_KEY = os.getenv("API_KEY")
22
+ BASE_URL = os.getenv("BASE_URL", "https://openrouter.ai/api/v1")
23
+ HF_TOKEN = os.getenv("HF_TOKEN") # pushするなら必要
24
+ DATASET_REPO = os.getenv("DATASET_REPO", "Toya0421/reading_exercise_logging")
25
+
26
+ # pushを有効にするか(負荷テスト中は 0 推奨)
27
+ ENABLE_HF_PUSH = os.getenv("ENABLE_HF_PUSH", "0") == "1"
28
+
29
+ # push間隔(秒)
30
+ PUSH_INTERVAL_SEC = int(os.getenv("PUSH_INTERVAL_SEC", "300")) # 5分
31
+ # push時に出すparquet一時ファイル名
32
+ PARQUET_NAME = "data.parquet"
33
 
34
  LOG_DB = "reading_logs.sqlite"
35
  CACHE_DIR = "rewrite_cache"
36
  os.makedirs(CACHE_DIR, exist_ok=True)
37
 
38
+ client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
39
+
40
+ # passage_information.xlsx 読み込み
41
  passage_info_df = pd.read_excel("passage_information.xlsx")
42
 
43
+ # =========================
44
+ # SQLite (WAL) for logs
45
+ # =========================
46
  _db_lock = threading.Lock()
47
+ _push_lock = threading.Lock()
48
 
49
  def init_db():
50
  with _db_lock:
 
67
 
68
  init_db()
69
 
70
+ def now_jst_iso():
71
+ return (datetime.utcnow() + timedelta(hours=9)).isoformat()
72
+
73
  def save_log(entry: dict):
74
+ # 1イベント=1INSERT(軽
75
  with _db_lock:
76
  conn = sqlite3.connect(LOG_DB, check_same_thread=False)
77
  conn.execute("PRAGMA journal_mode=WAL;")
 
90
  conn.commit()
91
  conn.close()
92
 
93
+ def export_sqlite_to_parquet(parquet_path: str):
94
+ # DB全体を読み出してparquet化(pushは低頻度でOK)
95
+ with _db_lock:
96
+ conn = sqlite3.connect(LOG_DB, check_same_thread=False)
97
+ df = pd.read_sql_query("SELECT * FROM logs ORDER BY id ASC", conn)
98
+ conn.close()
99
+ df.to_parquet(parquet_path, index=False)
100
+
101
+ def push_to_hub_if_enabled():
102
+ if not ENABLE_HF_PUSH:
103
+ return
104
+ if not HF_TOKEN:
105
+ print("[WARN] ENABLE_HF_PUSH=1 ですが HF_TOKEN がありません。pushをスキップします。")
106
+ return
107
+
108
+ # pushが重なると壊れるのでロック
109
+ if not _push_lock.acquire(blocking=False):
110
+ return
111
+ try:
112
+ tmp_dir = tempfile.mkdtemp()
113
+ parquet_path = os.path.join(tmp_dir, PARQUET_NAME)
114
+ export_sqlite_to_parquet(parquet_path)
115
+ dataset = Dataset.from_parquet(parquet_path)
116
+ dataset.push_to_hub(DATASET_REPO, token=HF_TOKEN)
117
+ print(f"[INFO] Pushed logs to hub: {DATASET_REPO} ({len(dataset)} rows)")
118
+ except Exception as e:
119
+ print(f"[ERROR] push_to_hub failed: {e}")
120
+ finally:
121
+ _push_lock.release()
122
+
123
+ def start_periodic_pusher():
124
+ # Spacesはプロセスが1つとは限らないので、push頻度は低め推奨
125
+ if not ENABLE_HF_PUSH:
126
+ return
127
+
128
+ def loop():
129
+ while True:
130
+ time.sleep(PUSH_INTERVAL_SEC)
131
+ push_to_hub_if_enabled()
132
+
133
+ th = threading.Thread(target=loop, daemon=True)
134
+ th.start()
135
+
136
+ start_periodic_pusher()
137
+
138
+ # =========================
139
+ # Passages
140
+ # =========================
141
  def list_passage_ids():
142
  files = glob.glob("passages/pg*.txt")
143
  ids = []
 
150
 
151
  ALL_PASSAGE_IDS = list_passage_ids()
152
 
153
+ def load_passage_file(text_id: int):
154
+ path = f"passages/pg{text_id}.txt"
155
+ if not os.path.exists(path):
156
+ return None
157
+ with open(path, "r", encoding="utf-8") as f:
158
+ return f.read()
159
+
160
+ def get_new_passage_random(used_passages_list):
161
+ # used_passages_list: list[int]
162
+ used = set(used_passages_list or [])
163
  if not ALL_PASSAGE_IDS:
164
+ return None, None, None, []
165
 
166
+ available = [pid for pid in ALL_PASSAGE_IDS if pid not in used]
167
  if not available:
168
+ used.clear()
169
  available = list(ALL_PASSAGE_IDS)
170
 
171
+ pid = random.choice(available)
172
+ used.add(pid)
173
 
174
+ text = load_passage_file(pid)
175
  if text is None:
176
+ return None, None, None, list(used)
177
 
178
+ row = passage_info_df[passage_info_df["Text#"] == pid]
179
  orig_level = None if len(row) == 0 else row.iloc[0]["flesch_score"]
180
+ return pid, text, orig_level, list(used)
181
 
182
+ # =========================
183
+ # Rewrite (cached)
184
+ # =========================
185
  def rewrite_cache_path(passage_id: int, level: int):
186
  return os.path.join(CACHE_DIR, f"pg{passage_id}_lv{level}.json")
187
 
188
  def rewrite_level(text: str, target_level: int, passage_id: int):
 
189
  cpath = rewrite_cache_path(passage_id, target_level)
190
  if os.path.exists(cpath):
191
+ try:
192
+ with open(cpath, "r", encoding="utf-8") as f:
193
+ return json.load(f)["rewritten"]
194
+ except Exception:
195
+ pass
196
 
197
  level_to_flesch = {1: 90, 2: 70, 3: 55, 4: 40, 5: 25}
198
  target_flesch = level_to_flesch[int(target_level)]
 
216
  )
217
  rewritten = resp.choices[0].message.content.strip()
218
 
219
+ try:
220
+ with open(cpath, "w", encoding="utf-8") as f:
221
+ json.dump({"rewritten": rewritten}, f, ensure_ascii=False)
222
+ except Exception:
223
+ pass
224
 
225
  return rewritten
226
 
227
  def split_pages(text, max_words=300):
228
  sentences = re.split(r'(?<=[.!?])\s+', text.strip())
229
  pages, current, wc = [], [], 0
 
230
  for s in sentences:
231
  w = s.split()
232
  if wc + len(w) > max_words and current:
 
235
  else:
236
  current.append(s)
237
  wc += len(w)
 
238
  if current:
239
  pages.append(" ".join(current))
 
240
  return pages or [text]
241
 
242
+ # =========================
243
+ # Gradio handlers (stateful per user)
244
+ # state = {"user_id": str|None, "level": int|None, "used_passages": list[int]}
245
+ # =========================
 
 
 
 
 
 
 
 
246
  def start_test(student_id, level_input, state):
247
  if state is None:
248
+ state = {"user_id": None, "level": None, "used_passages": []}
249
 
 
250
  if not student_id or str(student_id).strip() == "":
251
  return (
252
+ "", "", "[]", 0, 0, "", "", "",
253
  gr.update(interactive=False, visible=False),
254
  gr.update(interactive=False, visible=False),
255
  gr.update(interactive=False, visible=False),
 
258
 
259
  state["user_id"] = str(student_id).strip()
260
  state["level"] = int(level_input)
261
+ state["used_passages"] = []
262
 
 
263
  save_log({
264
  "user_id": state["user_id"],
265
  "assigned_level": state["level"],
 
270
  "page_text": None
271
  })
272
 
273
+ pid, text, orig_lev, used_list = get_new_passage_random(state["used_passages"])
274
+ state["used_passages"] = used_list
275
+
276
  if text is None:
277
  return (
278
+ "教材が見つかりません", "", "[]", 0, 0, "", "", "",
279
  gr.update(interactive=False, visible=False),
280
  gr.update(interactive=False, visible=False),
281
  gr.update(interactive=False, visible=False),
 
288
 
289
  prev_upd = gr.update(interactive=False, visible=False)
290
  next_upd = gr.update(interactive=(total > 1), visible=(total > 1))
291
+ finish_upd = gr.update(interactive=(total == 1), visible=(total == 1))
292
 
293
  save_log({
294
  "user_id": state["user_id"],
 
307
  0,
308
  total,
309
  str(pid),
310
+ "" if orig_lev is None else str(orig_lev),
311
  str(state["level"]),
312
  prev_upd, next_upd, finish_upd,
313
  state
 
316
  def next_page(pages_json, current_page, total_pages, pid, orig_lev, state):
317
  pages = json.loads(pages_json) if pages_json else []
318
  if not pages:
319
+ return ("", "", "[]", 0,
320
  gr.update(interactive=False, visible=False),
321
  gr.update(interactive=False, visible=False),
322
  gr.update(interactive=False, visible=False),
323
  state)
324
 
325
  save_log({
326
+ "user_id": state.get("user_id"),
327
+ "assigned_level": state.get("level"),
328
  "passage_id": int(pid),
329
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
330
  "action_time": now_jst_iso(),
 
332
  "page_text": None
333
  })
334
 
335
+ total_pages = int(total_pages)
336
+ current_page = int(current_page)
337
+ new_page = min(current_page + 1, total_pages - 1)
338
 
339
  save_log({
340
+ "user_id": state.get("user_id"),
341
+ "assigned_level": state.get("level"),
342
  "passage_id": int(pid),
343
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
344
  "action_time": now_jst_iso(),
 
347
  })
348
 
349
  prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0))
350
+ next_visible = (new_page < total_pages - 1)
351
  next_upd = gr.update(interactive=next_visible, visible=next_visible)
352
  finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible))
353
 
 
363
  def prev_page(pages_json, current_page, total_pages, pid, orig_lev, state):
364
  pages = json.loads(pages_json) if pages_json else []
365
  if not pages:
366
+ return ("", "", "[]", 0,
367
  gr.update(interactive=False, visible=False),
368
  gr.update(interactive=False, visible=False),
369
  gr.update(interactive=False, visible=False),
370
  state)
371
 
372
  save_log({
373
+ "user_id": state.get("user_id"),
374
+ "assigned_level": state.get("level"),
375
  "passage_id": int(pid),
376
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
377
  "action_time": now_jst_iso(),
 
379
  "page_text": None
380
  })
381
 
382
+ total_pages = int(total_pages)
383
+ current_page = int(current_page)
384
+ new_page = max(current_page - 1, 0)
385
 
386
  save_log({
387
+ "user_id": state.get("user_id"),
388
+ "assigned_level": state.get("level"),
389
  "passage_id": int(pid),
390
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
391
  "action_time": now_jst_iso(),
 
394
  })
395
 
396
  prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0))
397
+ next_visible = (new_page < total_pages - 1)
398
  next_upd = gr.update(interactive=next_visible, visible=next_visible)
399
  finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible))
400
 
 
409
 
410
  def finish_or_retire(pages_json, current_page, pid, orig_lev, action, state):
411
  save_log({
412
+ "user_id": state.get("user_id"),
413
+ "assigned_level": state.get("level"),
414
  "passage_id": int(pid),
415
  "original_level": float(orig_lev) if orig_lev not in ("", None) else None,
416
  "action_time": now_jst_iso(),
 
418
  "page_text": None
419
  })
420
 
421
+ new_pid, new_text, new_orig_lev, used_list = get_new_passage_random(state.get("used_passages", []))
422
+ state["used_passages"] = used_list
423
+
424
  if new_text is None:
425
  return (
426
+ "教材がありません", "", "[]", 0, 0, "", "", "",
427
  gr.update(interactive=False, visible=False),
428
  gr.update(interactive=False, visible=False),
429
  gr.update(interactive=False, visible=False),
 
436
 
437
  prev_upd = gr.update(interactive=False, visible=False)
438
  next_upd = gr.update(interactive=(total > 1), visible=(total > 1))
439
+ finish_upd = gr.update(interactive=(total == 1), visible=(total == 1))
440
 
441
  save_log({
442
+ "user_id": state.get("user_id"),
443
+ "assigned_level": state.get("level"),
444
  "passage_id": new_pid,
445
  "original_level": new_orig_lev,
446
  "action_time": now_jst_iso(),
 
455
  0,
456
  total,
457
  str(new_pid),
458
+ "" if new_orig_lev is None else str(new_orig_lev),
459
  str(state["level"]),
460
  prev_upd, next_upd, finish_upd,
461
  state
462
  )
463
 
464
+ # =========================
465
+ # Gradio queue/launch (version-safe)
466
+ # =========================
467
+ def safe_queue(blocks: gr.Blocks, concurrency: int = 8, max_size: int = 64):
468
+ sig = inspect.signature(blocks.queue)
469
+ kwargs = {}
470
+ # Gradioのバージョン差を吸収
471
+ if "default_concurrency_limit" in sig.parameters:
472
+ kwargs["default_concurrency_limit"] = concurrency
473
+ elif "concurrency_limit" in sig.parameters:
474
+ kwargs["concurrency_limit"] = concurrency
475
+ elif "concurrency_count" in sig.parameters:
476
+ kwargs["concurrency_count"] = concurrency
477
+ if "max_size" in sig.parameters:
478
+ kwargs["max_size"] = max_size
479
+ return blocks.queue(**kwargs)
480
+
481
+ def safe_launch(blocks: gr.Blocks):
482
+ sig = inspect.signature(blocks.launch)
483
+ kwargs = {}
484
+ # Spacesだと server_name/server_port は不要なことが多いが、あっても問題ない
485
+ if "server_name" in sig.parameters:
486
+ kwargs["server_name"] = "0.0.0.0"
487
+ if "server_port" in sig.parameters and os.getenv("PORT"):
488
+ kwargs["server_port"] = int(os.getenv("PORT"))
489
+ # max_threadsがある版だけ付ける
490
+ if "max_threads" in sig.parameters:
491
+ kwargs["max_threads"] = 16
492
+ return blocks.launch(**kwargs)
493
+
494
+ # =========================
495
+ # UI
496
+ # =========================
497
+ custom_css = """
498
+ .big-text {
499
+ font-size: 22px !important;
500
+ line-height: 1.8 !important;
501
+ font-family: "Noto Sans", sans-serif !important;
502
+ }
503
+ .reading-area {
504
+ padding: 20px !important;
505
+ border-radius: 12px !important;
506
+ border: 1px solid #ccc !important;
507
+ }
508
+ """
509
 
510
  with gr.Blocks(css=custom_css) as demo:
511
  gr.Markdown("# 📚 Reading Exercise")
 
514
  level_input = gr.Dropdown(choices=[1,2,3,4,5], label="Reading Level", value=3)
515
  start_btn = gr.Button("スタート")
516
 
517
+ text_display = gr.Textbox(label="教材", lines=18, interactive=False, elem_classes=["big-text", "reading-area"])
518
  page_display = gr.Textbox(label="進行状況", lines=1, interactive=False)
519
 
520
  hidden_pages = gr.Textbox(visible=False)
 
531
 
532
  retire_btn = gr.Button("リタイア")
533
 
534
+ state = gr.State({"user_id": None, "level": None, "used_passages": []})
535
 
536
  start_btn.click(
537
  fn=start_test,
 
584
  ],
585
  )
586
 
587
+ # queueはバージョン差があるので安全ラッパ
588
+ safe_queue(demo, concurrency=8, max_size=64)
589
+ safe_launch(demo)