Toya0421 commited on
Commit
150bf9a
·
verified ·
1 Parent(s): 3db36f1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +260 -397
app.py CHANGED
@@ -1,100 +1,123 @@
1
  import gradio as gr
2
  from openai import OpenAI
3
- 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):
31
- """
32
- passages/pg{text_id}.txt を読み込み、内容を返す。
33
- """
34
  path = f"passages/pg{text_id}.txt"
35
  if not os.path.exists(path):
36
  return None
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)
56
  num = name.replace("pg", "").replace(".txt", "")
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):
90
- level_to_flesch = {
91
- 1: 90,
92
- 2: 70,
93
- 3: 55,
94
- 4: 40,
95
- 5: 25
96
- }
97
 
 
98
  target_flesch = level_to_flesch[int(target_level)]
99
 
100
  prompt = f"""
@@ -102,9 +125,9 @@ Rewrite the following passage so it fits about {target_flesch} Flesch Reading Ea
102
  - Extract only the portions of the text that should be read as the main body,
103
  excluding the title, author name, source information, chapter number, annotations, and footers.
104
  - When outputting, make sure sections divided by chapters, etc., are clearly distinguishable by leaving a blank line between them.
105
- - Preserve the original meaning faithfully.
106
- - Do not add new information or remove essential information.
107
- - Output only the rewritten passage. Do not include explanations.
108
  {text}
109
  """
110
 
@@ -112,135 +135,102 @@ excluding the title, author name, source information, chapter number, annotation
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 = []
132
- current_word_count = 0
133
-
134
- for sentence in sentences:
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))
142
- current_page = [sentence]
143
- current_word_count = sentence_len
144
  else:
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,
191
- "action_type": action,
192
- "page_text": None
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
 
222
- if total == 1:
223
- prev_upd = gr.update(interactive=False, visible=False)
224
- next_upd = gr.update(interactive=False, visible=False)
225
- finish_upd = gr.update(interactive=True, visible=True)
226
- else:
227
- prev_upd = gr.update(interactive=False, visible=False)
228
- next_upd = gr.update(interactive=True, visible=True)
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],
@@ -248,176 +238,141 @@ def start_test(student_id, level_input):
248
  json.dumps(pages, ensure_ascii=False),
249
  0,
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,
273
- "action_type": "next_pushed",
274
- "page_text": None
275
- }
276
- save_log(entry)
277
-
278
- pages = json.loads(pages_json)
279
  if not pages:
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,
294
  "action_type": f"page_displayed_{new_page+1}",
295
  "page_text": pages[new_page]
296
- }
297
- save_log(entry2)
298
 
299
- if new_page == total_pages - 1:
300
- return (
301
- pages[new_page],
302
- f"{new_page+1} / {total_pages}",
303
- json.dumps(pages),
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 (
311
  pages[new_page],
312
  f"{new_page+1} / {total_pages}",
313
- json.dumps(pages),
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,
330
- "action_type": "prev_pushed",
331
- "page_text": None
332
- }
333
- save_log(entry)
334
-
335
- pages = json.loads(pages_json)
336
  if not pages:
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
 
344
  prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0))
345
- next_visible = (new_page < total_pages - 1)
346
  next_upd = gr.update(interactive=next_visible, visible=next_visible)
347
  finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible))
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,
356
- "action_type": f"page_displayed_{new_page+1}",
357
- "page_text": pages[new_page]
358
- }
359
- save_log(entry2)
360
-
361
  return (
362
  pages[new_page],
363
  f"{new_page+1} / {total_pages}",
364
- json.dumps(pages),
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,
382
  "action_type": action,
383
  "page_text": None
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
 
401
- if total == 1:
402
- prev_upd = gr.update(interactive=False, visible=False)
403
- next_upd = gr.update(interactive=False, visible=False)
404
- finish_upd = gr.update(interactive=True, visible=True)
405
- else:
406
- prev_upd = gr.update(interactive=False, visible=False)
407
- next_upd = gr.update(interactive=True, visible=True)
408
- finish_upd = gr.update(interactive=False, visible=False)
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,
417
  "action_type": "page_displayed_1",
418
  "page_text": new_pages[0]
419
- }
420
- save_log(entry2)
421
 
422
  return (
423
  new_pages[0],
@@ -425,107 +380,26 @@ def finish_or_retire(pages_json, current_page, pid, orig_lev, action):
425
  json.dumps(new_pages, ensure_ascii=False),
426
  0,
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
  # ======================================================
440
- custom_css = """
441
- /* ===============================
442
- 共通(両モード)
443
- =============================== */
444
- .big-text {
445
- font-size: 22px !important;
446
- line-height: 1.8 !important;
447
- font-family: "Noto Sans", sans-serif !important;
448
- }
449
- /* 教材表示ボックス */
450
- .reading-area {
451
- padding: 20px !important;
452
- border-radius: 12px !important;
453
- border: 1px solid #ccc !important;
454
- transition: background-color 0.2s ease, color 0.2s ease;
455
- }
456
- /* ===============================
457
- ライトモード
458
- =============================== */
459
- @media (prefers-color-scheme: light) {
460
- body, .gradio-container {
461
- background-color: #ffffff !important;
462
- color: #222 !important;
463
- }
464
- .reading-area {
465
- background-color: #fafafa !important;
466
- color: #222 !important;
467
- border-color: #ddd !important;
468
- }
469
- textarea, input, .gr-textbox textarea {
470
- background-color: #ffffff !important;
471
- color: #222 !important;
472
- border: 1px solid #ccc !important;
473
- }
474
- }
475
- /* ===============================
476
- ダークモード
477
- =============================== */
478
- @media (prefers-color-scheme: dark) {
479
- body, .gradio-container {
480
- background-color: #1e1e1e !important;
481
- color: #e6e6e6 !important;
482
- }
483
- /* 教材の背景は黒すぎると読みにくいのでやや明るめのチャコール */
484
- .reading-area {
485
- background-color: #2a2a2a !important;
486
- color: #f2f2f2 !important;
487
- border-color: #444 !important;
488
- }
489
- textarea, input, .gr-textbox textarea {
490
- background-color: #2c2c2c !important;
491
- color: #f0f0f0 !important;
492
- border: 1px solid #555 !important;
493
- }
494
- /* ボタンを見やすく */
495
- button {
496
- background-color: #3a3a3a !important;
497
- color: #f0f0f0 !important;
498
- border: 1px solid #555 !important;
499
- }
500
- button:hover {
501
- background-color: #4a4a4a !important;
502
- }
503
- .gr-panel, .gr-box, .gr-group {
504
- background-color: #272727 !important;
505
- border-color: #444 !important;
506
- }
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],
517
- label="あなたの Reading Level(Level Testの結果を選択)",
518
- value=3
519
- )
520
-
521
  start_btn = gr.Button("スタート")
522
 
523
- text_display = gr.Textbox(
524
- label="教材",
525
- lines=18,
526
- interactive=False,
527
- elem_classes=["big-text", "reading-area"]
528
- )
529
  page_display = gr.Textbox(label="進行状況", lines=1, interactive=False)
530
 
531
  hidden_pages = gr.Textbox(visible=False)
@@ -542,71 +416,60 @@ with gr.Blocks(css=custom_css) as demo:
542
 
543
  retire_btn = gr.Button("リタイア")
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
 
557
  next_btn.click(
558
  fn=next_page,
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
 
571
  prev_btn.click(
572
  fn=prev_page,
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()
 
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:
26
+ conn = sqlite3.connect(LOG_DB, check_same_thread=False)
27
+ conn.execute("PRAGMA journal_mode=WAL;")
28
+ conn.execute("""
29
+ CREATE TABLE IF NOT EXISTS logs (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ user_id TEXT,
32
+ assigned_level INTEGER,
33
+ passage_id INTEGER,
34
+ original_level REAL,
35
+ action_time TEXT,
36
+ action_type TEXT,
37
+ page_text TEXT
38
+ );
39
+ """)
40
+ conn.commit()
41
+ conn.close()
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;")
50
+ conn.execute("""
51
+ INSERT INTO logs(user_id, assigned_level, passage_id, original_level, action_time, action_type, page_text)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?)
53
+ """, (
54
+ entry.get("user_id"),
55
+ entry.get("assigned_level"),
56
+ entry.get("passage_id"),
57
+ entry.get("original_level"),
58
+ entry.get("action_time"),
59
+ entry.get("action_type"),
60
+ entry.get("page_text"),
61
+ ))
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 = []
 
 
 
 
78
  for f in files:
79
  name = os.path.basename(f)
80
  num = name.replace("pg", "").replace(".txt", "")
81
  if num.isdigit():
82
+ ids.append(int(num))
83
+ return sorted(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)]
122
 
123
  prompt = f"""
 
125
  - Extract only the portions of the text that should be read as the main body,
126
  excluding the title, author name, source information, chapter number, annotations, and footers.
127
  - When outputting, make sure sections divided by chapters, etc., are clearly distinguishable by leaving a blank line between them.
128
+ - Preserve the original meaning faithfully.
129
+ - Do not add new information or remove essential information.
130
+ - Output only the rewritten passage. Do not include explanations.
131
  {text}
132
  """
133
 
 
135
  model="google/gemini-2.5-flash",
136
  messages=[{"role": "user", "content": prompt}],
137
  temperature=0.4,
138
+ max_tokens=5000,
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:
155
+ pages.append(" ".join(current))
156
+ current, wc = [s], len(w)
 
 
 
 
 
 
 
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),
189
+ state
 
190
  )
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"],
200
+ "passage_id": None,
201
+ "original_level": None,
202
+ "action_time": now_jst_iso(),
203
+ "action_type": "start_pushed",
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),
214
+ state
215
  )
216
 
217
+ rewritten = rewrite_level(text, state["level"], pid)
218
  pages = split_pages(rewritten)
219
  total = len(pages)
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"],
227
+ "assigned_level": state["level"],
 
 
 
 
 
 
 
 
228
  "passage_id": pid,
229
  "original_level": orig_lev,
230
+ "action_time": now_jst_iso(),
231
+ "action_type": "page_displayed_1",
232
  "page_text": pages[0]
233
+ })
 
234
 
235
  return (
236
  pages[0],
 
238
  json.dumps(pages, ensure_ascii=False),
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
 
246
  )
247
 
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(),
263
+ "action_type": "next_pushed",
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(),
 
275
  "action_type": f"page_displayed_{new_page+1}",
276
  "page_text": pages[new_page]
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
 
284
  return (
285
  pages[new_page],
286
  f"{new_page+1} / {total_pages}",
287
+ json.dumps(pages, ensure_ascii=False),
288
  new_page,
289
+ prev_upd, next_upd, finish_upd,
290
+ state
 
291
  )
292
 
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(),
308
+ "action_type": "prev_pushed",
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(),
320
+ "action_type": f"page_displayed_{new_page+1}",
321
+ "page_text": pages[new_page]
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
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  return (
330
  pages[new_page],
331
  f"{new_page+1} / {total_pages}",
332
+ json.dumps(pages, ensure_ascii=False),
333
  new_page,
334
+ prev_upd, next_upd, finish_upd,
335
+ state
 
336
  )
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(),
 
 
 
 
345
  "action_type": action,
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),
356
+ state
357
  )
358
 
359
+ rewritten = rewrite_level(new_text, state["level"], new_pid)
360
  new_pages = split_pages(rewritten)
361
  total = len(new_pages)
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(),
373
  "action_type": "page_displayed_1",
374
  "page_text": new_pages[0]
375
+ })
 
376
 
377
  return (
378
  new_pages[0],
 
380
  json.dumps(new_pages, ensure_ascii=False),
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")
397
 
398
  student_id_input = gr.Textbox(label="学生番号(必須)")
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
 
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,
423
+ inputs=[student_id_input, level_input, state],
424
  outputs=[
425
  text_display, page_display,
426
  hidden_pages, hidden_page_index,
427
  hidden_total_pages, hidden_passage_id,
428
  hidden_orig_lev, hidden_assigned_lev,
429
+ prev_btn, next_btn, finish_btn,
430
+ state
431
+ ],
432
  )
433
 
434
  next_btn.click(
435
  fn=next_page,
436
+ inputs=[hidden_pages, hidden_page_index, hidden_total_pages, hidden_passage_id, hidden_orig_lev, state],
437
+ outputs=[text_display, page_display, hidden_pages, hidden_page_index, prev_btn, next_btn, finish_btn, state],
 
 
 
 
 
 
 
 
438
  )
439
 
440
  prev_btn.click(
441
  fn=prev_page,
442
+ inputs=[hidden_pages, hidden_page_index, hidden_total_pages, hidden_passage_id, hidden_orig_lev, state],
443
+ outputs=[text_display, page_display, hidden_pages, hidden_page_index, prev_btn, next_btn, finish_btn, state],
 
 
 
 
 
 
 
 
444
  )
445
 
446
  finish_btn.click(
447
+ fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "finished", st),
448
+ inputs=[hidden_pages, hidden_page_index, hidden_passage_id, hidden_orig_lev, state],
449
  outputs=[
450
  text_display, page_display,
451
  hidden_pages, hidden_page_index,
452
  hidden_total_pages, hidden_passage_id,
453
  hidden_orig_lev, hidden_assigned_lev,
454
+ prev_btn, next_btn, finish_btn,
455
+ state
456
+ ],
457
  )
458
 
459
  retire_btn.click(
460
+ fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "retire", st),
461
+ inputs=[hidden_pages, hidden_page_index, hidden_passage_id, hidden_orig_lev, state],
 
 
 
462
  outputs=[
463
  text_display, page_display,
464
  hidden_pages, hidden_page_index,
465
  hidden_total_pages, hidden_passage_id,
466
  hidden_orig_lev, hidden_assigned_lev,
467
+ prev_btn, next_btn, finish_btn,
468
+ state
469
+ ],
470
  )
471
 
472
+ # ★同時実行を制御(重要)
473
+ demo.queue(concurrency_count=8, max_size=64)
474
+
475
  demo.launch()