Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| from openai import OpenAI | |
| from datasets import Dataset | |
| from datetime import datetime, timedelta | |
| import pandas as pd | |
| import time, os, random, tempfile, json, glob | |
| import re | |
| import threading | |
| import csv | |
| # ★追加:Flesch計測 | |
| import textstat | |
| # --- API / HF 設定 --- | |
| API_KEY = os.getenv("API_KEY") | |
| BASE_URL = "https://openrouter.ai/api/v1" | |
| HF_TOKEN = os.getenv("HF_TOKEN") | |
| DATASET_REPO = "Toya0421/reading_exercise_logging" | |
| # ログは Spaces の永続ストレージ(= Files)へ | |
| LOG_FILE = "log.csv" | |
| client = OpenAI(base_url=BASE_URL, api_key=API_KEY) | |
| # --- passage_information.xlsx 読み込み (Text# と flesch_score 使用) --- | |
| passage_info_df = pd.read_excel("passage_information.xlsx") | |
| # ====================================================== | |
| # (③) 重い処理の同時実行制限:rewrite API を最大N並列に制限 | |
| # ====================================================== | |
| REWRITE_CONCURRENCY = int(os.getenv("REWRITE_CONCURRENCY", "3")) # 5,6人想定なら 2〜3 推奨 | |
| _rewrite_sem = threading.Semaphore(REWRITE_CONCURRENCY) | |
| # ====================================================== | |
| # (①) ログ:DatasetにもFilesにも保存しない | |
| # → メモリ上に保持し、パスワード付きでCSVダウンロード | |
| # ====================================================== | |
| _log_lock = threading.Lock() | |
| # ★CSVの列順を固定(headerもこの順で出す) | |
| LOG_COLUMNS = [ | |
| "user_id", | |
| "group", | |
| "assigned_level", | |
| "passage_id", | |
| "original_level", | |
| "flesch_score", # ★追加:Group1=orig_lev, Group2=rewritten fre | |
| "reading_order", # ★追加:そのユーザーが何番目の教材を読んでいるか | |
| "action_time", | |
| "action_type", | |
| "page_text", | |
| ] | |
| # ★追加:メモリログ(Filesに保存しない) | |
| _LOG_ROWS: list[dict] = [] | |
| # ★追加:ダウンロード用パスワード | |
| LOG_DOWNLOAD_PASSWORD = os.getenv("LOG_DOWNLOAD_PASSWORD", "0421") | |
| def save_log(entry): | |
| """ | |
| 毎アクション:メモリに追記のみ(FilesにもDatasetにも保存しない) | |
| """ | |
| row = {k: entry.get(k, "") for k in LOG_COLUMNS} | |
| with _log_lock: | |
| _LOG_ROWS.append(row) | |
| def export_logs_to_csv_file() -> str: | |
| """ | |
| 現在のメモリログを一時CSVにしてパスを返す | |
| """ | |
| with _log_lock: | |
| rows = list(_LOG_ROWS) | |
| tmp_dir = tempfile.mkdtemp() | |
| # ✅ DL用ファイル名を exercise_logs_日時 にする(JST) | |
| ts = (datetime.utcnow() + timedelta(hours=9)).strftime("%Y%m%d_%H%M") | |
| path = os.path.join(tmp_dir, f"exercise_logs_{ts}.csv") | |
| with open(path, "w", encoding="utf-8", newline="") as f: | |
| w = csv.DictWriter(f, fieldnames=LOG_COLUMNS) | |
| w.writeheader() | |
| w.writerows(rows) | |
| return path | |
| def download_log_csv(password: str) -> str: | |
| """ | |
| パスワードが一致した場合のみCSVを生成して返す | |
| """ | |
| if (password or "").strip() != LOG_DOWNLOAD_PASSWORD: | |
| raise gr.Error("パスワードが違います。") | |
| return export_logs_to_csv_file() | |
| # ====================================================== | |
| # 新しい教材管理:passages フォルダからランダム選択 | |
| # ※ used_passages は session_state に保持(グローバル禁止) | |
| # ★Group2:target level よりスコアが低い教材から選ぶ(excelのflesch_score) | |
| # ★Group1:全教材からランダム選択 | |
| # ====================================================== | |
| def load_passage_file(text_id): | |
| """ | |
| passages/pg{text_id}.txt を読み込み、内容を返す。 | |
| """ | |
| path = f"passages/pg{text_id}.txt" | |
| if not os.path.exists(path): | |
| return None | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read() | |
| def get_title_from_excel(text_id): | |
| """ | |
| passage_information.xlsx の Text# に対応する Title を取得する。 | |
| """ | |
| row = passage_info_df[passage_info_df["Text#"] == text_id] | |
| if len(row) == 0: | |
| return None | |
| title = row.iloc[0].get("Title", None) | |
| if pd.isna(title): | |
| return None | |
| return str(title) | |
| def get_new_passage_random(used_passages_set, target_level): | |
| """ | |
| ★Group2用: | |
| passages フォルダからランダムに教材を選び(pg◯.txt)、 | |
| passage_information.xlsx の Text# の flesch_score を original_level として返す。 | |
| - ユーザーの target_level に対応する目標FREよりも低い(=難しい)教材のみから選ぶ | |
| """ | |
| level_to_flesch = {1: 90, 2: 80, 3: 70, 4: 60, 5: 50} | |
| target_flesch = float(level_to_flesch[int(target_level)]) | |
| files = glob.glob("passages/pg*.txt") | |
| if not files: | |
| return None, None, None, None, used_passages_set | |
| all_ids = [] | |
| for f in files: | |
| name = os.path.basename(f) | |
| num = name.replace("pg", "").replace(".txt", "") | |
| if num.isdigit(): | |
| all_ids.append(int(num)) | |
| eligible_ids = [] | |
| for pid in all_ids: | |
| row = passage_info_df[passage_info_df["Text#"] == pid] | |
| if len(row) == 0: | |
| continue | |
| fs = row.iloc[0].get("flesch_score", None) | |
| try: | |
| fs = float(fs) | |
| except Exception: | |
| continue | |
| if fs < target_flesch: | |
| eligible_ids.append(pid) | |
| if not eligible_ids: | |
| return None, None, None, None, used_passages_set | |
| available = [pid for pid in eligible_ids if pid not in used_passages_set] | |
| if not available: | |
| used_passages_set = set() | |
| available = list(eligible_ids) | |
| text_id = random.choice(available) | |
| used_passages_set.add(text_id) | |
| text = load_passage_file(text_id) | |
| if text is None: | |
| return None, None, None, None, used_passages_set | |
| row = passage_info_df[passage_info_df["Text#"] == text_id] | |
| if len(row) == 0: | |
| orig_level = None | |
| title = None | |
| else: | |
| orig_level = row.iloc[0].get("flesch_score", None) | |
| title = row.iloc[0].get("Title", None) | |
| if pd.isna(title): | |
| title = None | |
| else: | |
| title = str(title) | |
| return text_id, text, orig_level, title, used_passages_set | |
| def get_new_passage_random_any(used_passages_set): | |
| """ | |
| ★Group1用:target_level による難易度フィルタなし | |
| passages フォルダ内の全教材からランダムに選ぶ。 | |
| original_level (=flesch_score) は passage_information.xlsx から取得して返す。 | |
| """ | |
| files = glob.glob("passages/pg*.txt") | |
| if not files: | |
| return None, None, None, None, used_passages_set | |
| all_ids = [] | |
| for f in files: | |
| name = os.path.basename(f) | |
| num = name.replace("pg", "").replace(".txt", "") | |
| if num.isdigit(): | |
| all_ids.append(int(num)) | |
| if not all_ids: | |
| return None, None, None, None, used_passages_set | |
| available = [pid for pid in all_ids if pid not in used_passages_set] | |
| if not available: | |
| used_passages_set = set() | |
| available = list(all_ids) | |
| text_id = random.choice(available) | |
| used_passages_set.add(text_id) | |
| text = load_passage_file(text_id) | |
| if text is None: | |
| return None, None, None, None, used_passages_set | |
| row = passage_info_df[passage_info_df["Text#"] == text_id] | |
| if len(row) == 0: | |
| orig_level = None | |
| title = None | |
| else: | |
| orig_level = row.iloc[0].get("flesch_score", None) | |
| title = row.iloc[0].get("Title", None) | |
| if pd.isna(title): | |
| title = None | |
| else: | |
| title = str(title) | |
| return text_id, text, orig_level, title, used_passages_set | |
| # ====================================================== | |
| # Group1: 本文のみ抽出(★LLMで、Group2と同等の基準) | |
| # ====================================================== | |
| def extract_main_body_llm(text: str) -> str: | |
| """ | |
| Group1用:書き換えはしない。 | |
| ただしGroup2と同等の基準で「本文以外を完全除外」させるため、LLMに本文抽出だけさせる。 | |
| """ | |
| prompt = f""" | |
| Extract ONLY the main body text from the following passage. | |
| Rules: | |
| - Completely EXCLUDE titles, headings, chapter labels, section numbers, | |
| author names, source information, footnotes, annotations, and introductions. | |
| - Treat verse numbers, line numbers, and numbering markers (e.g., "001:001") as non-body content and remove them. | |
| - Do NOT rewrite, paraphrase, summarize, or otherwise modify the text content. | |
| - Preserve the original paragraph structure of the main body text. | |
| (Do not treat line breaks caused by formatting or verse layout as paragraph breaks.) | |
| - Insert exactly ONE blank line between paragraphs. | |
| - Do NOT create new section breaks, chapter divisions, or headings. | |
| - Output only the extracted main body text. | |
| - Do not include explanations, comments, or metadata. | |
| - Do not include [TEXT START] and [TEXT END] in the output. | |
| [TEXT START] | |
| {text} | |
| [TEXT END] | |
| """.strip() | |
| with _rewrite_sem: | |
| resp = client.chat.completions.create( | |
| model="google/gemini-2.5-flash", | |
| messages=[{"role": "user", "content": prompt}], | |
| temperature=0.0, | |
| max_tokens=5000 | |
| ) | |
| return resp.choices[0].message.content.strip() | |
| # ====================================================== | |
| # Rewrite(同時実行制限付き) Group2で使用 | |
| # ★プロンプトを「改善後プロンプト」に置換 | |
| # ====================================================== | |
| def rewrite_level(text, target_level, original_fre): | |
| level_to_flesch = {1: 90, 2: 80, 3: 70, 4: 60, 5: 50} | |
| target_flesch = level_to_flesch[int(target_level)] | |
| try: | |
| original_fre_val = float(original_fre) | |
| except Exception: | |
| original_fre_val = float("nan") | |
| prompt = f""" | |
| Numerous Readability Manipulation: | |
| The Flesch Reading Ease score is a numeric measure of text readability, | |
| where higher scores indicate easier readability and lower scores indicate more difficult text. | |
| In this task, we are trying to rewrite a given text into the target Flesch Reading Ease score | |
| and preserving the original meaning and information. | |
| Given the original draft (Flesch Reading Ease = {original_fre_val}): | |
| [TEXT START] | |
| {text} | |
| [TEXT END] | |
| Rewrite the above text to the difficulty level of: | |
| Flesch Reading Ease = {target_flesch} | |
| Follow the instructions below carefully. | |
| Qualitative Readability Control | |
| ・Content preservation | |
| - Maintain the original meaning faithfully. | |
| - Do not add new information. | |
| - Do not remove important information. | |
| - Do not introduce interpretations or opinions that are not present in the original text. | |
| ・Clarity & Naturalness Refinement | |
| - Rewrite the text in clear, modern English. Remove archaic expressions and unnatural or outdated syntax typical of older texts at all levels. | |
| - Minimize figurative language, idioms, and expressions whose meanings are not directly inferable. | |
| - Use simple and direct sentence structures. | |
| - Prefer familiar, high-frequency vocabulary. | |
| - Avoid jargon; if technical terms are necessary, explain them clearly in simple language. | |
| - Maintain a natural flow of reading rather than a sequence of evenly shortened sentences. Reduce average sentence length while avoiding uniform or fixed-length sentences. Introduce natural variation in sentence length to maintain rhythm and avoid mechanical patterns. | |
| Formatting Constraints | |
| ・Scope of rewriting | |
| - Rewrite ONLY the main body text. | |
| - Completely EXCLUDE titles, headings, chapter labels, author names, source information, footnotes, annotations, and introductions. | |
| - Do NOT include any text other than the rewritten main body under any circumstances. | |
| ・Structure and formatting | |
| - Preserve the original paragraph structure of the main text. | |
| - Output only the rewritten text. | |
| - Do not include [TEXT START] and [TEXT END] in the output. | |
| """.strip() | |
| with _rewrite_sem: | |
| resp = client.chat.completions.create( | |
| model="google/gemini-2.5-flash", | |
| messages=[{"role": "user", "content": prompt}], | |
| temperature=0.4, | |
| max_tokens=5000 | |
| ) | |
| return resp.choices[0].message.content.strip() | |
| def split_pages(text, max_words=300): | |
| sentences = re.split(r'(?<=[.!?])\s+', text.strip()) | |
| pages = [] | |
| current_page = [] | |
| current_word_count = 0 | |
| for sentence in sentences: | |
| words = sentence.split() | |
| sentence_len = len(words) | |
| if current_word_count + sentence_len > max_words: | |
| if current_page: | |
| pages.append(" ".join(current_page)) | |
| current_page = [sentence] | |
| current_word_count = sentence_len | |
| else: | |
| current_page.append(sentence) | |
| current_word_count += sentence_len | |
| if current_page: | |
| pages.append(" ".join(current_page)) | |
| return pages or [text] | |
| # ====================================================== | |
| # Start(session_stateでユーザー状態管理) | |
| # ★Start後:入力はリロードまで固定(1回だけ) | |
| # ====================================================== | |
| def start_test(student_id, level_input, group_input, session_state): | |
| action = "start_pushed" | |
| now = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| # ★Start押下時点で入力を固定(成功/失敗問わず、リロードまで不可) | |
| lock_student = gr.update(interactive=False) | |
| lock_group = gr.update(interactive=False) | |
| lock_level = gr.update(interactive=False) | |
| lock_start = gr.update(interactive=False) | |
| if not student_id or str(student_id).strip() == "": | |
| entry = { | |
| "user_id": None, | |
| "group": None, | |
| "assigned_level": None, | |
| "passage_id": None, | |
| "original_level": None, | |
| "flesch_score": None, | |
| "reading_order": 0, # ★追加 | |
| "action_time": now, | |
| "action_type": action, | |
| "page_text": None | |
| } | |
| save_log(entry) | |
| return ( | |
| "", | |
| "", | |
| "", | |
| json.dumps([]), | |
| 0, | |
| 0, | |
| "", | |
| "", | |
| None, | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=True), | |
| gr.update(interactive=False, visible=False), | |
| session_state, | |
| lock_student, lock_group, lock_level, lock_start | |
| ) | |
| user_id = str(student_id).strip() | |
| level = int(level_input) | |
| group = int(group_input) # ★group_inputは "1"/"2" でもOK | |
| used_passages_set = set() | |
| entry = { | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": None, | |
| "original_level": None, | |
| "flesch_score": None, | |
| "reading_order": 0, # ★追加(教材未表示なので0) | |
| "action_time": now, | |
| "action_type": action, | |
| "page_text": None | |
| } | |
| save_log(entry) | |
| # ★変更:Group1は全教材、Group2はレベル制限あり | |
| if group == 1: | |
| pid, text, orig_lev, title, used_passages_set = get_new_passage_random_any(used_passages_set) | |
| else: | |
| pid, text, orig_lev, title, used_passages_set = get_new_passage_random(used_passages_set, level) | |
| if text is None: | |
| return ( | |
| "", | |
| "教材が見つかりません", | |
| "", | |
| json.dumps([]), | |
| 0, | |
| 0, | |
| "", | |
| "", | |
| None, | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| session_state, | |
| lock_student, lock_group, lock_level, lock_start | |
| ) | |
| if group == 1: | |
| processed = extract_main_body_llm(text) # ★ここが変更点 | |
| measured_fre = orig_lev # ★要件:Group1はpassage_informationのflesch_scoreを記録 | |
| else: | |
| processed = rewrite_level(text, level, orig_lev) | |
| # ★要件:Group2は書き換え後をtextstatで計測して記録 | |
| try: | |
| measured_fre = float(textstat.flesch_reading_ease(processed)) | |
| except Exception: | |
| measured_fre = None | |
| pages = split_pages(processed) | |
| total = len(pages) | |
| if total == 1: | |
| prev_upd = gr.update(interactive=False, visible=False) | |
| next_upd = gr.update(interactive=False, visible=False) | |
| finish_upd = gr.update(interactive=True, visible=True) | |
| else: | |
| prev_upd = gr.update(interactive=False, visible=False) | |
| next_upd = gr.update(interactive=True, visible=True) | |
| finish_upd = gr.update(interactive=False, visible=False) | |
| reading_order = 1 # ★追加:このユーザーの最初の教材は1番目 | |
| now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": measured_fre, # ★追加 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now2, | |
| "action_type": "page_displayed_1", | |
| "page_text": pages[0] | |
| }) | |
| session_state = { | |
| "user_id": user_id, | |
| "level": level, | |
| "group": group, | |
| "used_passages": list(used_passages_set), | |
| "reading_order": reading_order # ★追加 | |
| } | |
| return ( | |
| f"**Title:** {title}" if title else "**Title:** (No Title)", | |
| pages[0], | |
| f"1 / {total}", | |
| json.dumps(pages, ensure_ascii=False), | |
| 0, | |
| total, | |
| pid, | |
| orig_lev, | |
| level, | |
| prev_upd, | |
| next_upd, | |
| finish_upd, | |
| session_state, | |
| lock_student, lock_group, lock_level, lock_start | |
| ) | |
| # ====================================================== | |
| # Next / Prev / Finish(元コードのままの構造 + state参照) | |
| # ====================================================== | |
| def next_page(pages_json, current_page, total_pages, pid, orig_lev, session_state): | |
| user_id = session_state.get("user_id") | |
| level = session_state.get("level") | |
| group = session_state.get("group") | |
| reading_order = session_state.get("reading_order", 1) # ★追加 | |
| now = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": "", # ★列維持 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now, | |
| "action_type": "next_pushed", | |
| "page_text": None | |
| }) | |
| pages = json.loads(pages_json) | |
| if not pages: | |
| return ("", "", json.dumps([]), 0, | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| session_state) | |
| new_page = min(current_page + 1, total_pages - 1) | |
| now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": "", # ★列維持 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now2, | |
| "action_type": f"page_displayed_{new_page+1}", | |
| "page_text": pages[new_page] | |
| }) | |
| if new_page == total_pages - 1: | |
| return ( | |
| pages[new_page], | |
| f"{new_page+1} / {total_pages}", | |
| json.dumps(pages), | |
| new_page, | |
| gr.update(interactive=True, visible=True), | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=True, visible=True), | |
| session_state | |
| ) | |
| return ( | |
| pages[new_page], | |
| f"{new_page+1} / {total_pages}", | |
| json.dumps(pages), | |
| new_page, | |
| gr.update(interactive=(new_page > 0), visible=(new_page > 0)), | |
| gr.update(interactive=True, visible=True), | |
| gr.update(interactive=False, visible=False), | |
| session_state | |
| ) | |
| def prev_page(pages_json, current_page, total_pages, pid, orig_lev, session_state): | |
| user_id = session_state.get("user_id") | |
| level = session_state.get("level") | |
| group = session_state.get("group") | |
| reading_order = session_state.get("reading_order", 1) # ★追加 | |
| now = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": "", # ★列維持 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now, | |
| "action_type": "prev_pushed", | |
| "page_text": None | |
| }) | |
| pages = json.loads(pages_json) | |
| if not pages: | |
| return ("", "", json.dumps([]), 0, | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| session_state) | |
| new_page = max(current_page - 1, 0) | |
| prev_upd = gr.update(interactive=(new_page > 0), visible=(new_page > 0)) | |
| next_visible = (new_page < total_pages - 1) | |
| next_upd = gr.update(interactive=next_visible, visible=next_visible) | |
| finish_upd = gr.update(interactive=(not next_visible), visible=(not next_visible)) | |
| now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": "", # ★列維持 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now2, | |
| "action_type": f"page_displayed_{new_page+1}", | |
| "page_text": pages[new_page] | |
| }) | |
| return ( | |
| pages[new_page], | |
| f"{new_page+1} / {total_pages}", | |
| json.dumps(pages), | |
| new_page, | |
| prev_upd, | |
| next_upd, | |
| finish_upd, | |
| session_state | |
| ) | |
| def finish_or_retire(pages_json, current_page, pid, orig_lev, action, session_state): | |
| user_id = session_state.get("user_id") | |
| level = session_state.get("level") | |
| group = session_state.get("group") | |
| used_passages_set = set(session_state.get("used_passages", [])) | |
| reading_order = int(session_state.get("reading_order", 1)) # ★追加 | |
| pages = json.loads(pages_json) | |
| now = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": pid, | |
| "original_level": orig_lev, | |
| "flesch_score": "", # ★列維持 | |
| "reading_order": reading_order, # ★追加 | |
| "action_time": now, | |
| "action_type": action, | |
| "page_text": None | |
| }) | |
| # ★変更:Group1は全教材、Group2はレベル制限あり | |
| if group == 1: | |
| new_pid, new_text, new_orig_lev, title, used_passages_set = get_new_passage_random_any(used_passages_set) | |
| else: | |
| new_pid, new_text, new_orig_lev, title, used_passages_set = get_new_passage_random(used_passages_set, level) | |
| if new_text is None: | |
| return ( | |
| "", "教材がありません", "", json.dumps([]), 0, "", | |
| 0, "", None, None, | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| gr.update(interactive=False, visible=False), | |
| session_state | |
| ) | |
| if group == 1: | |
| processed = extract_main_body_llm(new_text) # ★ここが変更点 | |
| measured_fre = new_orig_lev | |
| else: | |
| processed = rewrite_level(new_text, level, new_orig_lev) | |
| try: | |
| measured_fre = float(textstat.flesch_reading_ease(processed)) | |
| except Exception: | |
| measured_fre = None | |
| new_pages = split_pages(processed) | |
| total = len(new_pages) | |
| if total == 1: | |
| prev_upd = gr.update(interactive=False, visible=False) | |
| next_upd = gr.update(interactive=False, visible=False) | |
| finish_upd = gr.update(interactive=True, visible=True) | |
| else: | |
| prev_upd = gr.update(interactive=False, visible=False) | |
| next_upd = gr.update(interactive=True, visible=True) | |
| finish_upd = gr.update(interactive=False, visible=False) | |
| new_reading_order = reading_order + 1 # ★追加:次の教材なので+1 | |
| now2 = (datetime.utcnow() + timedelta(hours=9)).isoformat() | |
| save_log({ | |
| "user_id": user_id, | |
| "group": group, | |
| "assigned_level": level, | |
| "passage_id": new_pid, | |
| "original_level": new_orig_lev, | |
| "flesch_score": measured_fre, # ★追加 | |
| "reading_order": new_reading_order, # ★追加 | |
| "action_time": now2, | |
| "action_type": "page_displayed_1", | |
| "page_text": new_pages[0] | |
| }) | |
| session_state = { | |
| "user_id": user_id, | |
| "level": level, | |
| "group": group, | |
| "used_passages": list(used_passages_set), | |
| "reading_order": new_reading_order # ★追加 | |
| } | |
| return ( | |
| f"**Title:** {title}" if title else "**Title:** (No Title)", | |
| new_pages[0], | |
| f"1 / {total}", | |
| json.dumps(new_pages, ensure_ascii=False), | |
| 0, | |
| total, | |
| new_pid, | |
| new_orig_lev, | |
| level, | |
| prev_upd, | |
| next_upd, | |
| finish_upd, | |
| session_state | |
| ) | |
| # ====================================================== | |
| # ★追加:入力状況で Start ボタンの有効/無効を切り替え | |
| # - 学籍番号が空なら Start は押せない | |
| # - group / level はデフォルトがあるので必須チェック不要(必要なら追加可) | |
| # ====================================================== | |
| def toggle_start_button(student_id): | |
| ok = bool((student_id or "").strip()) | |
| return gr.update(interactive=ok) | |
| # ====================================================== | |
| # UI(タイトル表示を追加。それ以外は変更しない) | |
| # ★追加:パスワード付きログCSVダウンロード | |
| # ★修正:JSは使わずCSSのみ(スタートが進まない問題を回避) | |
| # ====================================================== | |
| custom_css = """ | |
| /* =============================== | |
| 共通(両モード) | |
| =============================== */ | |
| .big-text { | |
| font-size: 22px !important; | |
| line-height: 1.8 !important; | |
| font-family: "Noto Sans", sans-serif !important; | |
| } | |
| .reading-area { | |
| padding: 20px !important; | |
| border-radius: 12px !important; | |
| border: 1px solid #ccc !important; | |
| transition: background-color 0.2s ease, color 0.2s ease; | |
| } | |
| .gradio-container label, .gradio-container .wrap label { | |
| color: inherit !important; | |
| } | |
| .gradio-container input[type="radio"], | |
| .gradio-container input[type="checkbox"] { | |
| accent-color: #2563eb !important; | |
| } | |
| .gradio-container select { | |
| color: inherit !important; | |
| } | |
| /* =============================== | |
| ライトモード | |
| =============================== */ | |
| @media (prefers-color-scheme: light) { | |
| body, .gradio-container { | |
| background-color: #ffffff !important; | |
| color: #222 !important; | |
| } | |
| .gr-panel, .gr-box, .gr-group { | |
| background-color: #ffffff !important; | |
| border-color: #ddd !important; | |
| } | |
| .reading-area { | |
| background-color: #fafafa !important; | |
| color: #222 !important; | |
| border-color: #ddd !important; | |
| } | |
| textarea, input, .gr-textbox textarea { | |
| background-color: #ffffff !important; | |
| color: #222 !important; | |
| border: 1px solid #ccc !important; | |
| } | |
| select, .gr-dropdown select { | |
| background-color: #ffffff !important; | |
| color: #222 !important; | |
| border: 1px solid #ccc !important; | |
| } | |
| .gr-radio label, .gr-radio .wrap label, .gr-radio span { | |
| color: #222 !important; | |
| } | |
| button { | |
| background-color: #f5f5f5 !important; | |
| color: #111 !important; | |
| border: 1px solid #ccc !important; | |
| } | |
| button:hover { | |
| background-color: #eaeaea !important; | |
| } | |
| } | |
| /* =============================== | |
| ダークモード | |
| =============================== */ | |
| @media (prefers-color-scheme: dark) { | |
| body, .gradio-container { | |
| background-color: #1e1e1e !important; | |
| color: #e6e6e6 !important; | |
| } | |
| .reading-area { | |
| background-color: #2a2a2a !important; | |
| color: #f2f2f2 !important; | |
| border-color: #444 !important; | |
| } | |
| textarea, input, .gr-textbox textarea { | |
| background-color: #2c2c2c !important; | |
| color: #f0f0f0 !important; | |
| border: 1px solid #555 !important; | |
| } | |
| select, .gr-dropdown select { | |
| background-color: #2c2c2c !important; | |
| color: #f0f0f0 !important; | |
| border: 1px solid #555 !important; | |
| } | |
| button { | |
| background-color: #3a3a3a !important; | |
| color: #f0f0f0 !important; | |
| border: 1px solid #555 !important; | |
| } | |
| button:hover { | |
| background-color: #4a4a4a !important; | |
| } | |
| .gr-panel, .gr-box, .gr-group { | |
| background-color: #272727 !important; | |
| border-color: #444 !important; | |
| } | |
| .gr-radio label, .gr-radio .wrap label, .gr-radio span { | |
| color: #e6e6e6 !important; | |
| } | |
| } | |
| /* =============================== | |
| ★Group選択:CSSのみで見やすく(EdgeでもOK) | |
| =============================== */ | |
| #group_radio input[type="radio"]{ | |
| appearance: auto !important; | |
| -webkit-appearance: radio !important; | |
| width: 18px !important; | |
| height: 18px !important; | |
| accent-color: #2563eb !important; | |
| } | |
| #group_radio label{ | |
| width: 100% !important; | |
| box-sizing: border-box !important; | |
| padding: 10px 12px !important; | |
| border-radius: 12px !important; | |
| border: 1px solid #e5e7eb !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| gap: 10px !important; | |
| } | |
| /* :has が効く環境は行全体ハイライト */ | |
| @media (prefers-color-scheme: light){ | |
| #group_radio label:has(input[type="radio"]:checked){ | |
| background: #eef2ff !important; | |
| border: 2px solid #4f46e5 !important; | |
| } | |
| } | |
| @media (prefers-color-scheme: dark){ | |
| #group_radio label:has(input[type="radio"]:checked){ | |
| background: #1f2937 !important; | |
| border: 2px solid #60a5fa !important; | |
| } | |
| } | |
| """ | |
| with gr.Blocks(css=custom_css) as demo: | |
| gr.Markdown("# 📚 Reading Exercise") | |
| session_state = gr.State({"user_id": None, "level": None, "group": 2, "used_passages": [], "reading_order": 0}) # ★追加 | |
| student_id_input = gr.Textbox(label="Student ID") | |
| group_input = gr.Radio( | |
| choices=[("Group 1", "1"), ("Group 2", "2")], | |
| label="実験グループを選択", | |
| value="2", | |
| elem_id="group_radio" | |
| ) | |
| level_input = gr.Dropdown( | |
| choices=[1,2,3,4,5], | |
| label="あなたの Reading Level(Level Testの結果を選択)", | |
| value=3 | |
| ) | |
| # ✅ 初期状態では押せない(学籍番号が空だから) | |
| start_btn = gr.Button("スタート", interactive=False) | |
| # ✅ 学籍番号の入力に応じて Start の有効/無効を切り替え | |
| student_id_input.change( | |
| fn=toggle_start_button, | |
| inputs=[student_id_input], | |
| outputs=[start_btn] | |
| ) | |
| title_display = gr.Markdown("**Title:** ", elem_classes=["title-card"]) | |
| text_display = gr.Textbox( | |
| label="教材", | |
| lines=18, | |
| interactive=False, | |
| elem_classes=["big-text", "reading-area"] | |
| ) | |
| page_display = gr.Textbox(label="進行状況", lines=1, interactive=False) | |
| hidden_pages = gr.Textbox(visible=False) | |
| hidden_page_index = gr.Number(visible=False) | |
| hidden_total_pages = gr.Number(visible=False) | |
| hidden_passage_id = gr.Textbox(visible=False) | |
| hidden_orig_lev = gr.Textbox(visible=False) | |
| hidden_assigned_lev = gr.Textbox(visible=False) | |
| with gr.Row(): | |
| prev_btn = gr.Button("◀ 前へ", interactive=False, visible=False) | |
| next_btn = gr.Button("次へ ▶", interactive=False, visible=False) | |
| finish_btn = gr.Button("読み終えた", interactive=False, visible=False) | |
| retire_btn = gr.Button("リタイア") | |
| # ★Start後に入力をロックするため、入力コンポーネントもoutputsに追加 | |
| start_btn.click( | |
| fn=start_test, | |
| inputs=[student_id_input, level_input, group_input, session_state], | |
| outputs=[ | |
| title_display, | |
| text_display, page_display, | |
| hidden_pages, hidden_page_index, | |
| hidden_total_pages, hidden_passage_id, | |
| hidden_orig_lev, hidden_assigned_lev, | |
| prev_btn, next_btn, finish_btn, | |
| session_state, | |
| student_id_input, group_input, level_input, start_btn | |
| ] | |
| ) | |
| next_btn.click( | |
| fn=next_page, | |
| inputs=[ | |
| hidden_pages, hidden_page_index, | |
| hidden_total_pages, hidden_passage_id, | |
| hidden_orig_lev, session_state | |
| ], | |
| outputs=[ | |
| text_display, page_display, | |
| hidden_pages, hidden_page_index, | |
| prev_btn, next_btn, finish_btn, | |
| session_state | |
| ] | |
| ) | |
| prev_btn.click( | |
| fn=prev_page, | |
| inputs=[ | |
| hidden_pages, hidden_page_index, | |
| hidden_total_pages, hidden_passage_id, | |
| hidden_orig_lev, session_state | |
| ], | |
| outputs=[ | |
| text_display, page_display, | |
| hidden_pages, hidden_page_index, | |
| prev_btn, next_btn, finish_btn, | |
| session_state | |
| ] | |
| ) | |
| finish_btn.click( | |
| fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "finished", st), | |
| inputs=[hidden_pages, hidden_page_index, hidden_passage_id, hidden_orig_lev, session_state], | |
| outputs=[ | |
| title_display, | |
| text_display, page_display, | |
| hidden_pages, hidden_page_index, | |
| hidden_total_pages, hidden_passage_id, | |
| hidden_orig_lev, hidden_assigned_lev, | |
| prev_btn, next_btn, finish_btn, | |
| session_state | |
| ] | |
| ) | |
| retire_btn.click( | |
| fn=lambda p, i, pid, o, st: finish_or_retire(p, i, pid, o, "retire", st), | |
| inputs=[ | |
| hidden_pages, hidden_page_index, | |
| hidden_passage_id, hidden_orig_lev, | |
| session_state | |
| ], | |
| outputs=[ | |
| title_display, | |
| text_display, page_display, | |
| hidden_pages, hidden_page_index, | |
| hidden_total_pages, hidden_passage_id, | |
| hidden_orig_lev, hidden_assigned_lev, | |
| prev_btn, next_btn, finish_btn, | |
| session_state | |
| ] | |
| ) | |
| gr.Markdown("## 🔐 管理者用:ログCSVダウンロード(パスワード必須)") | |
| admin_password = gr.Textbox(label="Password", type="password") | |
| download_btn = gr.Button("ログCSVを生成してダウンロード") | |
| download_file = gr.File(label="Download log.csv") | |
| download_btn.click( | |
| fn=download_log_csv, | |
| inputs=[admin_password], | |
| outputs=[download_file] | |
| ) | |
| demo.queue(max_size=64) | |
| demo.launch() | |