Toya0421 commited on
Commit
b476bed
·
verified ·
1 Parent(s): 7652c73

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +114 -71
app.py CHANGED
@@ -12,6 +12,7 @@ API_KEY = os.getenv("API_KEY")
12
  BASE_URL = "https://openrouter.ai/api/v1"
13
  LOG_FILE = os.getenv("LOG_FILE", "logs.csv")
14
 
 
15
  ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "0421")
16
 
17
  client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
@@ -21,10 +22,11 @@ passages_df = pd.read_csv("passage.csv")
21
 
22
  levels = [1, 2, 3, 4, 5]
23
 
 
24
  _log_lock = threading.Lock()
25
 
26
  # =========================
27
- # ログ列(固定・指定順)
28
  # =========================
29
  LOG_COLUMNS = [
30
  "user_id",
@@ -39,7 +41,7 @@ LOG_COLUMNS = [
39
  ]
40
 
41
  # =========================
42
- # 文字化け防止(ASCII正規化)
43
  # =========================
44
  def normalize_text_for_csv(text: str) -> str:
45
  if not text:
@@ -51,19 +53,20 @@ def normalize_text_for_csv(text: str) -> str:
51
  .replace("‘", "'")
52
  .replace("“", '"')
53
  .replace("”", '"')
 
54
  )
55
 
56
- # --- セッション state ---
57
  def _new_session_state():
58
  return {
59
  "session_id": str(uuid.uuid4()),
60
  "used_passages": set(),
61
- "question_count": 0,
62
  "user_id": None,
63
- "action_log": [],
64
  }
65
 
66
- # --- CSV追記(固定) ---
67
  def log_to_csv(entry: dict):
68
  with _log_lock:
69
  row = {col: entry.get(col, None) for col in LOG_COLUMNS}
@@ -80,19 +83,16 @@ def log_to_csv(entry: dict):
80
  )
81
 
82
  def log_event(state: dict, **kwargs):
 
83
  now = (datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S") + " JST"
84
 
85
- # ✅ 正規化(ここが追加ポイント
86
- if "question" in kwargs:
87
  kwargs["question"] = normalize_text_for_csv(kwargs["question"])
88
  if "actions" in kwargs and isinstance(kwargs["actions"], str):
89
  kwargs["actions"] = normalize_text_for_csv(kwargs["actions"])
90
 
91
- entry = {
92
- "time": now,
93
- "user_id": state.get("user_id"),
94
- **kwargs
95
- }
96
  log_to_csv(entry)
97
 
98
  # --- AIで問題生成 ---
@@ -135,13 +135,14 @@ Respond with one word: "Correct" or "Incorrect".
135
  temperature=0,
136
  )
137
  result = response.choices[0].message.content.strip().lower()
 
138
  if re.search(r"\bincorrect\b", result):
139
  return False
140
  if re.search(r"\bcorrect\b", result):
141
  return True
142
  return False
143
 
144
- # --- 難易度調整 ---
145
  def adaptive_test(prev_level, prev_correct):
146
  idx = levels.index(prev_level)
147
  if prev_correct and idx < len(levels) - 1:
@@ -160,7 +161,7 @@ def get_passage(level, used_passages):
160
  text = subset[subset["passage_id"] == passage_id]["text"].iloc[0]
161
  return passage_id, text
162
 
163
- # --- Start Test ---
164
  def start_test(student_id, state):
165
  state = _new_session_state()
166
 
@@ -179,7 +180,9 @@ def start_test(student_id, state):
179
  state["used_passages"].add(passage_id)
180
 
181
  question = generate_question(text)
 
182
 
 
183
  log_event(
184
  state,
185
  question_number=1,
@@ -191,29 +194,28 @@ def start_test(student_id, state):
191
  actions="start test pushed",
192
  )
193
 
194
- displayed_time = datetime.utcnow() + timedelta(hours=9)
195
-
196
  return (
197
  state,
198
  text, question, level, passage_id, None,
199
  "", True, displayed_time.isoformat(), 1, state["user_id"]
200
  )
201
 
202
- # --- 選択変更(CSVには書かない) ---
203
  def log_choice_change(choice, question_number, user_id, state):
 
 
204
  if choice:
205
  t_iso = (datetime.utcnow() + timedelta(hours=9)).isoformat()
206
- state["action_log"].append({
207
- "action": "choice",
208
- "choice": choice,
209
- "time": t_iso
210
- })
211
  return state
212
 
213
- # --- Submit ---
214
  def next_step(prev_level, user_answer, question_text, passage_text,
215
  displayed_time, question_number, user_id, passage_id, state):
216
 
 
 
 
217
  if not user_answer:
218
  return (
219
  state,
@@ -221,9 +223,13 @@ def next_step(prev_level, user_answer, question_text, passage_text,
221
  None, "", True, displayed_time, question_number, user_id, passage_id
222
  )
223
 
 
224
  state["question_count"] += 1
 
225
  correct = check_answer_with_ai(passage_text, question_text, user_answer)
 
226
 
 
227
  log_event(
228
  state,
229
  question_number=state["question_count"],
@@ -235,96 +241,133 @@ def next_step(prev_level, user_answer, question_text, passage_text,
235
  actions=json.dumps(state["action_log"], ensure_ascii=False),
236
  )
237
 
 
238
  if state["question_count"] >= 5:
 
239
  return (
240
  state,
241
- f"<h1>🎯 Your Reading level: <strong>Level {prev_level}</strong></h1>",
242
- "", "", prev_level,
243
  None, "", False, "", "", user_id, passage_id
244
  )
245
 
246
- next_level = adaptive_test(prev_level, correct)
247
- next_passage_id, next_text = get_passage(next_level, state["used_passages"])
248
  state["used_passages"].add(next_passage_id)
249
 
250
  next_question = generate_question(next_text)
 
 
 
251
  state["action_log"] = []
252
 
 
 
253
  return (
254
  state,
255
- "➡️ Loading next question…",
256
- next_text, next_question, next_level,
257
- None, "", True,
258
- (datetime.utcnow() + timedelta(hours=9)).isoformat(),
259
- state["question_count"] + 1,
260
- user_id,
261
- next_passage_id
262
  )
263
 
264
- # --- Admin download ---
265
  def download_logs(admin_password):
266
- if admin_password != ADMIN_PASSWORD:
 
 
 
267
  return None, "❌ Password incorrect."
268
 
269
- if not os.path.exists(LOG_FILE):
270
- return None, "⚠️ logs.csv が存在しません"
 
271
 
272
- tmp_dir = tempfile.mkdtemp()
273
- out_path = os.path.join(tmp_dir, "logs.csv")
274
- with open(LOG_FILE, "rb") as fsrc, open(out_path, "wb") as fdst:
275
- fdst.write(fsrc.read())
276
 
277
  return out_path, "✅ logs.csv is ready."
278
 
279
- # --- UI ---
280
  with gr.Blocks() as demo:
281
  gr.Markdown("# 📘 Reading Level Test")
282
 
283
  session_state = gr.State(_new_session_state())
284
 
285
- student_id_input = gr.Textbox(label="Student ID")
286
  start_btn = gr.Button("▶️ Start Test")
287
 
288
- text_display = gr.Textbox(label="Reading Passage", lines=15)
289
- question_display = gr.Textbox(label="Question", lines=6)
290
- user_answer = gr.Radio(["A", "B", "C", "D"])
291
  submit_btn = gr.Button("Submit Answer")
292
 
293
- feedback = gr.Markdown()
294
 
295
  hidden_level = gr.Number(visible=False)
296
- hidden_time = gr.Textbox(visible=False)
297
- hidden_qnum = gr.Number(visible=False)
298
- hidden_uid = gr.Textbox(visible=False)
299
- hidden_pid = gr.Textbox(visible=False)
 
 
300
 
301
  start_btn.click(
302
- start_test,
303
- [student_id_input, session_state],
304
- [session_state, text_display, question_display, hidden_level, hidden_pid,
305
- user_answer, feedback, hidden_time, hidden_qnum, hidden_uid]
 
 
 
 
306
  )
307
 
 
308
  user_answer.change(
309
- log_choice_change,
310
- [user_answer, hidden_qnum, hidden_uid, session_state],
311
- [session_state]
312
  )
313
 
314
  submit_btn.click(
315
- next_step,
316
- [hidden_level, user_answer, question_display, text_display,
317
- hidden_time, hidden_qnum, hidden_uid, hidden_pid, session_state],
318
- [session_state, feedback, text_display, question_display, hidden_level,
319
- user_answer, hidden_time, hidden_qnum, hidden_uid, hidden_pid]
 
 
 
 
320
  )
321
 
322
- gr.Markdown("## 🔒 Admin")
323
- pw = gr.Textbox(type="password")
324
- dl = gr.Button("Download logs.csv")
325
- out = gr.File()
326
- msg = gr.Markdown()
 
327
 
328
- dl.click(download_logs, [pw], [out, msg])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  demo.launch()
 
12
  BASE_URL = "https://openrouter.ai/api/v1"
13
  LOG_FILE = os.getenv("LOG_FILE", "logs.csv")
14
 
15
+ # ✅ 管理者DL用パスワード(環境変数で設定)
16
  ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "0421")
17
 
18
  client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
 
22
 
23
  levels = [1, 2, 3, 4, 5]
24
 
25
+ # ✅ ログの同時書き込みガード(ログは全ユーザーで共有)
26
  _log_lock = threading.Lock()
27
 
28
  # =========================
29
+ # ログ列(指定順)
30
  # =========================
31
  LOG_COLUMNS = [
32
  "user_id",
 
41
  ]
42
 
43
  # =========================
44
+ # 文字化け対策方法②:ASCII 正規化)
45
  # =========================
46
  def normalize_text_for_csv(text: str) -> str:
47
  if not text:
 
53
  .replace("‘", "'")
54
  .replace("“", '"')
55
  .replace("”", '"')
56
+ .replace("\u00a0", " ") # NBSP 対策(地味に効く)
57
  )
58
 
59
+ # --- セッション state 初期化 ---
60
  def _new_session_state():
61
  return {
62
  "session_id": str(uuid.uuid4()),
63
  "used_passages": set(),
64
+ "question_count": 0, # ✅ 採点済み問題数
65
  "user_id": None,
66
+ "action_log": [], # ✅ 1問中の選択変更履歴(submit時にまとめて保存)
67
  }
68
 
69
+ # --- ログ追記(CSVのみ・固定カラムで統一) ---
70
  def log_to_csv(entry: dict):
71
  with _log_lock:
72
  row = {col: entry.get(col, None) for col in LOG_COLUMNS}
 
83
  )
84
 
85
  def log_event(state: dict, **kwargs):
86
+ # ✅ Excelで#####になりにくいよう「JST」を付けてテキスト化
87
  now = (datetime.utcnow() + timedelta(hours=9)).strftime("%Y-%m-%d %H:%M:%S") + " JST"
88
 
89
+ # ✅ 文字化け対策(CSVに書く直前で正規化)
90
+ if "question" in kwargs and isinstance(kwargs["question"], str):
91
  kwargs["question"] = normalize_text_for_csv(kwargs["question"])
92
  if "actions" in kwargs and isinstance(kwargs["actions"], str):
93
  kwargs["actions"] = normalize_text_for_csv(kwargs["actions"])
94
 
95
+ entry = {"time": now, "user_id": state.get("user_id"), **kwargs}
 
 
 
 
96
  log_to_csv(entry)
97
 
98
  # --- AIで問題生成 ---
 
135
  temperature=0,
136
  )
137
  result = response.choices[0].message.content.strip().lower()
138
+
139
  if re.search(r"\bincorrect\b", result):
140
  return False
141
  if re.search(r"\bcorrect\b", result):
142
  return True
143
  return False
144
 
145
+ # --- 自動難易度調整 ---
146
  def adaptive_test(prev_level, prev_correct):
147
  idx = levels.index(prev_level)
148
  if prev_correct and idx < len(levels) - 1:
 
161
  text = subset[subset["passage_id"] == passage_id]["text"].iloc[0]
162
  return passage_id, text
163
 
164
+ # --- 開始ボタン動作(✅ start時だけログ) ---
165
  def start_test(student_id, state):
166
  state = _new_session_state()
167
 
 
180
  state["used_passages"].add(passage_id)
181
 
182
  question = generate_question(text)
183
+ displayed_time = datetime.utcnow() + timedelta(hours=9)
184
 
185
+ # ✅ startログ:actions列に "start test pushed" を入れる
186
  log_event(
187
  state,
188
  question_number=1,
 
194
  actions="start test pushed",
195
  )
196
 
 
 
197
  return (
198
  state,
199
  text, question, level, passage_id, None,
200
  "", True, displayed_time.isoformat(), 1, state["user_id"]
201
  )
202
 
203
+ # --- 選択変更(CSVには書かない。actionsにだけ溜める) ---
204
  def log_choice_change(choice, question_number, user_id, state):
205
+ if not isinstance(state, dict):
206
+ state = _new_session_state()
207
  if choice:
208
  t_iso = (datetime.utcnow() + timedelta(hours=9)).isoformat()
209
+ state["action_log"].append({"action": "choice", "choice": choice, "time": t_iso})
 
 
 
 
210
  return state
211
 
212
+ # --- 回答送信(✅ submit時だけログ) ---
213
  def next_step(prev_level, user_answer, question_text, passage_text,
214
  displayed_time, question_number, user_id, passage_id, state):
215
 
216
+ if not isinstance(state, dict):
217
+ state = _new_session_state()
218
+
219
  if not user_answer:
220
  return (
221
  state,
 
223
  None, "", True, displayed_time, question_number, user_id, passage_id
224
  )
225
 
226
+ submit_time = datetime.utcnow() + timedelta(hours=9)
227
  state["question_count"] += 1
228
+
229
  correct = check_answer_with_ai(passage_text, question_text, user_answer)
230
+ new_level = adaptive_test(prev_level, correct)
231
 
232
+ # ✅ submitログ(ここだけ記録)
233
  log_event(
234
  state,
235
  question_number=state["question_count"],
 
241
  actions=json.dumps(state["action_log"], ensure_ascii=False),
242
  )
243
 
244
+ # ✅ 最終問題なら結果だけ大きく表示(仕様そのまま)
245
  if state["question_count"] >= 5:
246
+ final_level = prev_level if correct else new_level
247
  return (
248
  state,
249
+ f"<h1>🎯 Your Reading level: <strong>Level {final_level}</strong></h1>",
250
+ "", "", final_level,
251
  None, "", False, "", "", user_id, passage_id
252
  )
253
 
254
+ # --- 次の問題へ ---
255
+ next_passage_id, next_text = get_passage(new_level, state["used_passages"])
256
  state["used_passages"].add(next_passage_id)
257
 
258
  next_question = generate_question(next_text)
259
+ next_display_time = datetime.utcnow() + timedelta(hours=9)
260
+
261
+ # ✅ 次の問題のために選択変更履歴をリセット
262
  state["action_log"] = []
263
 
264
+ feedback = "✅ Correct!" if correct else "❌ Incorrect."
265
+
266
  return (
267
  state,
268
+ feedback + "\n➡️ Loading next question…",
269
+ next_text, next_question, new_level,
270
+ None, "", True, next_display_time.isoformat(), state["question_count"] + 1, user_id, next_passage_id
 
 
 
 
271
  )
272
 
273
+ # --- 管理者DL ---
274
  def download_logs(admin_password):
275
+ if not ADMIN_PASSWORD:
276
+ return None, "⚠️ ADMIN_PASSWORD が設定されていません(環境変数で設定してください)"
277
+
278
+ if (admin_password or "") != ADMIN_PASSWORD:
279
  return None, "❌ Password incorrect."
280
 
281
+ with _log_lock:
282
+ if not os.path.exists(LOG_FILE):
283
+ return None, "⚠️ logs.csv がまだ存在しません(ログが1件もありません)"
284
 
285
+ tmp_dir = tempfile.mkdtemp()
286
+ out_path = os.path.join(tmp_dir, "logs.csv")
287
+ with open(LOG_FILE, "rb") as fsrc, open(out_path, "wb") as fdst:
288
+ fdst.write(fsrc.read())
289
 
290
  return out_path, "✅ logs.csv is ready."
291
 
292
+ # --- Gradio UI ---
293
  with gr.Blocks() as demo:
294
  gr.Markdown("# 📘 Reading Level Test")
295
 
296
  session_state = gr.State(_new_session_state())
297
 
298
+ student_id_input = gr.Textbox(label="Student ID", placeholder="例: B123456")
299
  start_btn = gr.Button("▶️ Start Test")
300
 
301
+ text_display = gr.Textbox(label="Reading Passage", lines=15, interactive=False)
302
+ question_display = gr.Textbox(label="Question", lines=6, interactive=False)
303
+ user_answer = gr.Radio(["A", "B", "C", "D"], label="Your Answer")
304
  submit_btn = gr.Button("Submit Answer")
305
 
306
+ feedback_display = gr.Markdown()
307
 
308
  hidden_level = gr.Number(visible=False)
309
+ hidden_passage = gr.Textbox(visible=False) # 互換用(未使用)
310
+ hidden_display_time = gr.Textbox(visible=False)
311
+ hidden_question_number = gr.Number(visible=False)
312
+ hidden_user_id = gr.Textbox(visible=False)
313
+ hidden_passage_id = gr.Textbox(visible=False)
314
+ test_visible = gr.State(False)
315
 
316
  start_btn.click(
317
+ fn=start_test,
318
+ inputs=[student_id_input, session_state],
319
+ outputs=[
320
+ session_state,
321
+ text_display, question_display, hidden_level, hidden_passage_id, user_answer,
322
+ feedback_display, test_visible, hidden_display_time,
323
+ hidden_question_number, hidden_user_id
324
+ ]
325
  )
326
 
327
+ # ✅ 選択肢変更はCSVに書かず、actionsにだけ溜める
328
  user_answer.change(
329
+ fn=log_choice_change,
330
+ inputs=[user_answer, hidden_question_number, hidden_user_id, session_state],
331
+ outputs=[session_state]
332
  )
333
 
334
  submit_btn.click(
335
+ fn=next_step,
336
+ inputs=[hidden_level, user_answer, question_display, text_display,
337
+ hidden_display_time, hidden_question_number, hidden_user_id, hidden_passage_id, session_state],
338
+ outputs=[
339
+ session_state,
340
+ feedback_display, text_display, question_display, hidden_level,
341
+ user_answer, hidden_passage, test_visible, hidden_display_time,
342
+ hidden_question_number, hidden_user_id, hidden_passage_id
343
+ ]
344
  )
345
 
346
+ def toggle_visibility(show):
347
+ v = bool(show)
348
+ return (
349
+ gr.update(visible=v), gr.update(visible=v),
350
+ gr.update(visible=v), gr.update(visible=v)
351
+ )
352
 
353
+ test_visible.change(
354
+ fn=toggle_visibility,
355
+ inputs=test_visible,
356
+ outputs=[text_display, question_display, user_answer, submit_btn]
357
+ )
358
+
359
+ gr.Markdown("---")
360
+ gr.Markdown("## 🔒 Admin: Download logs (since last restart)")
361
+
362
+ admin_pw = gr.Textbox(label="Admin Password", type="password", placeholder="Enter admin password")
363
+ dl_btn = gr.Button("⬇️ Download logs.csv")
364
+ dl_file = gr.File(label="logs.csv (download)")
365
+ dl_msg = gr.Markdown()
366
+
367
+ dl_btn.click(
368
+ fn=download_logs,
369
+ inputs=[admin_pw],
370
+ outputs=[dl_file, dl_msg]
371
+ )
372
 
373
  demo.launch()