| """Ryugaku - Backyard AI hackathon Gradio app. |
| |
| Study assistant for international students in Japan. |
| Tabbed scenes: Translate, Lecture, Sheet, Review. |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| import os |
| from typing import Any, Dict, List |
|
|
| import gradio as gr |
|
|
| from src.exam_predictor import predict_exam_topics |
| from src.keyword_extractor import extract_keywords |
| from src.ocr import ocr_image |
| from src.summarizer import summarize, easy_english |
| from src.stt import transcribe_and_translate |
| from src.translator import translate |
|
|
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
|
|
| CUSTOM_CSS = """ |
| #ryu-app { |
| height: 100vh; |
| width: 100vw; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| #ryu-app > .form { |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| gap: 0; |
| } |
| |
| .ryu-header { |
| flex: 0 0 auto; |
| padding: 8px 16px; |
| } |
| |
| .ryu-course label, |
| .ryu-course .label-wrap { |
| display: none !important; |
| } |
| |
| .ryu-tabs { |
| flex: 1 1 auto; |
| min-height: 0; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| .ryu-tabs .tab-content { |
| flex: 1 1 auto; |
| min-height: 0; |
| overflow-y: auto; |
| padding: 12px; |
| } |
| |
| .ryu-tabs .tab-content .tabitem > .form { |
| height: 100%; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .ryu-pane-row { |
| flex: 1 1 auto; |
| min-height: 0; |
| display: flex; |
| gap: 8px; |
| } |
| |
| .ryu-pane-row > .column { |
| flex: 1 1 50%; |
| min-width: 0; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .ryu-pane-row .block { |
| height: 100%; |
| } |
| |
| .ryu-toolbar { |
| flex: 0 0 auto; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| padding: 0 12px; |
| } |
| |
| .ryu-hint { |
| font-size: 13px; |
| padding: 0 8px 8px; |
| } |
| |
| .ryu-help { |
| border-radius: 8px; |
| padding: 12px 16px; |
| margin: 0 8px; |
| } |
| |
| .ryu-status { |
| flex: 0 0 auto; |
| padding: 0 16px; |
| font-size: 12px; |
| } |
| |
| .ryu-center { |
| flex: 0 0 auto; |
| display: flex; |
| justify-content: center; |
| padding: 16px; |
| } |
| |
| @media (max-width: 768px) { |
| .ryu-pane-row { |
| flex-direction: column; |
| } |
| } |
| """ |
|
|
|
|
| def _fmt_items(items: List[Dict[str, Any]], mode: str) -> str: |
| if not items: |
| return "" |
| if mode == "keywords": |
| return "\n".join( |
| f"{it.get('word', '')} ({it.get('reading', '')}) — {it.get('meaning', '')}".strip(" —") |
| for it in items[:20] |
| ) |
| if mode == "exam": |
| parts = [] |
| for i, it in enumerate(items[:10], 1): |
| body = "" |
| if it.get("topic"): |
| body += f"Topic: {it['topic']}\n" |
| if it.get("question"): |
| body += f"Q: {it['question']}\n" |
| if it.get("sample_answer"): |
| body += f"A: {it['sample_answer']}\n" |
| parts.append(f"[{i}]\n{body.strip()}") |
| return "\n\n".join(parts) |
| return "" |
|
|
|
|
| def on_translate(source: str) -> str: |
| if not source.strip(): |
| return "" |
| out, _ = translate(source) |
| return out |
|
|
|
|
| def on_summarize(source: str) -> str: |
| if not source.strip(): |
| return "" |
| out, _ = summarize(source) |
| return out |
|
|
|
|
| def on_easy(source: str) -> str: |
| if not source.strip(): |
| return "" |
| out, _ = easy_english(source) |
| return out |
|
|
|
|
| def on_keywords(source: str) -> str: |
| if not source.strip(): |
| return "" |
| items, _ = extract_keywords(source) |
| return _fmt_items(items, "keywords") |
|
|
|
|
| def on_exam(source: str) -> str: |
| if not source.strip(): |
| return "" |
| items, _ = predict_exam_topics(source) |
| return _fmt_items(items, "exam") |
|
|
|
|
| def on_audio(audio_path: str): |
| transcript, translation, status = transcribe_and_translate(audio_path) |
| return transcript, translation, status or "" |
|
|
|
|
| def on_image(image): |
| if image is None: |
| return "", "", "" |
| detected, translated = ocr_image(image) |
| if detected.startswith("OCR error:") or detected == "(no text detected)": |
| return "", "", detected |
| return detected, translated, "" |
|
|
|
|
| def add_course(name: str, courses: List[Dict[str, Any]]): |
| if not name or not name.strip(): |
| return courses, None, "" |
| new_id = str(max([int(c["id"]) for c in courses], default=0) + 1) |
| courses.append({"id": new_id, "name": name.strip()}) |
| return courses, new_id, f"コース「{name.strip()}」を追加しました。" |
|
|
|
|
| def update_dropdown(courses: List[Dict[str, Any]]): |
| return gr.Dropdown(choices={c["name"]: c["id"] for c in courses}, value=courses[-1]["id"] if courses else None) |
|
|
|
|
| def show_help(): |
| return ( |
| "【使い方】\n" |
| "• 翻訳:日本語を入力すると自動で英語に翻訳します。\n" |
| "• 音声:マイクボタンを押して話してください。録音停止後に日本語の文字起こしと英訳が表示されます。\n" |
| "• 画像翻訳:画像をドラッグ&ドロップまたはクリックしてアップロードすると、写っている日本語を読み取って英訳します。\n" |
| "• 復習:授業のテキストを貼り付けて、下のボタンから要約・かんたん英語・単語帳・試験予測を生成します。\n" |
| "• コース:ヘッダーの「新しいコース名」に入力して「追加」を押すと、翻訳履歴を分けて管理するコースを作れます。" |
| ) |
|
|
|
|
| def toggle_help(visible: bool): |
| return not visible, gr.update(visible=not visible) |
|
|
|
|
| with gr.Blocks( |
| title="Ryugaku", |
| elem_id="ryu-app", |
| css=CUSTOM_CSS, |
| theme=gr.themes.Default(), |
| ) as demo: |
| courses_state = gr.State(value=[{"id": "1", "name": "デフォルト"}]) |
|
|
| with gr.Row(elem_classes=["ryu-header"]): |
| gr.Markdown("## Ryugaku") |
| with gr.Row(elem_classes=["ryu-course"]): |
| gr.Markdown("コース:") |
| course_dropdown = gr.Dropdown( |
| label="", |
| choices={"デフォルト": "1"}, |
| value="1", |
| show_label=False, |
| container=False, |
| interactive=True, |
| scale=1, |
| allow_custom_value=True, |
| ) |
| new_course_name = gr.Textbox( |
| label="", |
| placeholder="新しいコース名", |
| show_label=False, |
| container=False, |
| scale=1, |
| ) |
| add_course_btn = gr.Button("追加", scale=0) |
| help_visible = gr.State(False) |
| help_btn = gr.Button("? 使い方", scale=0) |
|
|
| help_text = gr.Textbox( |
| label="", |
| value=show_help(), |
| interactive=False, |
| show_label=False, |
| container=False, |
| lines=6, |
| visible=False, |
| elem_classes=["ryu-help"], |
| ) |
|
|
| with gr.Tabs(elem_classes=["ryu-tabs"]): |
| with gr.Tab("翻訳 / Translate"): |
| gr.Markdown( |
| '<div class="ryu-hint">日本語を入力すると、右側に英語訳が表示されます。</div>' |
| ) |
| with gr.Row(elem_classes=["ryu-pane-row"]): |
| translate_source = gr.Textbox( |
| label="日本語", |
| placeholder="ここに日本語を入力または貼り付け...", |
| lines=18, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
| translate_output = gr.Textbox( |
| label="English", |
| placeholder="English translation will appear here...", |
| interactive=False, |
| lines=18, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-toolbar"]): |
| translate_btn = gr.Button("翻訳", variant="primary") |
| clear_translate_btn = gr.Button("クリア") |
|
|
| with gr.Row(elem_classes=["ryu-status"]): |
| translate_status = gr.Textbox( |
| label="", |
| interactive=False, |
| show_label=False, |
| container=False, |
| value="", |
| ) |
|
|
| with gr.Tab("音声 / Lecture"): |
| gr.Markdown( |
| '<div class="ryu-hint">マイクボタンを押して話してください。録音停止後に文字起こしと英訳が表示されます。</div>' |
| ) |
| with gr.Row(elem_classes=["ryu-center"]): |
| lecture_audio = gr.Audio( |
| sources=["microphone"], |
| type="filepath", |
| label="マイクで録音", |
| show_label=True, |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-pane-row"]): |
| lecture_ja = gr.Textbox( |
| label="文字起こし(日本語)", |
| placeholder="ここに日本語の文字起こしが表示されます...", |
| lines=12, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
| lecture_en = gr.Textbox( |
| label="翻訳(English)", |
| placeholder="English translation will appear here...", |
| interactive=False, |
| lines=12, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-status"]): |
| lecture_status = gr.Textbox( |
| label="", |
| interactive=False, |
| show_label=False, |
| container=False, |
| value="", |
| ) |
|
|
| with gr.Tab("画像翻訳 / Sheet"): |
| gr.Markdown( |
| '<div class="ryu-hint">画像をドラッグ&ドロップするか、枠内をクリックしてアップロードしてください。写っている日本語を読み取って英訳します。</div>' |
| ) |
| with gr.Row(elem_classes=["ryu-center"]): |
| sheet_image = gr.Image( |
| type="pil", |
| label="画像をアップロード", |
| show_label=True, |
| sources=["upload"], |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-pane-row"]): |
| sheet_ja = gr.Textbox( |
| label="読み取った日本語", |
| placeholder="ここにOCRで読み取った日本語が表示されます...", |
| lines=12, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
| sheet_en = gr.Textbox( |
| label="翻訳(English)", |
| placeholder="English translation will appear here...", |
| interactive=False, |
| lines=12, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-status"]): |
| sheet_status = gr.Textbox( |
| label="", |
| interactive=False, |
| show_label=False, |
| container=False, |
| value="", |
| ) |
|
|
| with gr.Tab("復習 / Review"): |
| gr.Markdown( |
| '<div class="ryu-hint">授業のテキストを貼り付けて、下のボタンから復習コンテンツを生成してください。</div>' |
| ) |
| with gr.Row(elem_classes=["ryu-review-input"]): |
| review_text = gr.Textbox( |
| label="授業のテキスト", |
| placeholder="ここに授業の原稿やノートを貼り付け...", |
| lines=8, |
| show_label=True, |
| container=True, |
| scale=1, |
| ) |
|
|
| with gr.Row(elem_classes=["ryu-toolbar"]): |
| summary_btn = gr.Button("要約") |
| easy_btn = gr.Button("かんたん英語") |
| keywords_btn = gr.Button("単語帳") |
| exam_btn = gr.Button("試験予測") |
| clear_review_btn = gr.Button("クリア") |
|
|
| with gr.Row(elem_classes=["ryu-result"]): |
| review_output = gr.Textbox( |
| label="結果", |
| lines=10, |
| interactive=False, |
| show_label=True, |
| container=True, |
| placeholder="ここに要約・かんたん英語・単語帳・試験予測が表示されます...", |
| ) |
|
|
| |
| translate_source.change(fn=on_translate, inputs=translate_source, outputs=translate_output) |
| translate_btn.click(fn=on_translate, inputs=translate_source, outputs=translate_output) |
| clear_translate_btn.click( |
| fn=lambda: ("", ""), |
| outputs=[translate_source, translate_output], |
| ) |
|
|
| lecture_audio.change( |
| fn=on_audio, |
| inputs=lecture_audio, |
| outputs=[lecture_ja, lecture_en, lecture_status], |
| ) |
|
|
| sheet_image.change( |
| fn=on_image, |
| inputs=sheet_image, |
| outputs=[sheet_ja, sheet_en, sheet_status], |
| ) |
|
|
| summary_btn.click(fn=on_summarize, inputs=review_text, outputs=review_output) |
| easy_btn.click(fn=on_easy, inputs=review_text, outputs=review_output) |
| keywords_btn.click(fn=on_keywords, inputs=review_text, outputs=review_output) |
| exam_btn.click(fn=on_exam, inputs=review_text, outputs=review_output) |
| clear_review_btn.click( |
| fn=lambda: ("", ""), |
| outputs=[review_text, review_output], |
| ) |
|
|
| add_course_btn.click( |
| fn=add_course, |
| inputs=[new_course_name, courses_state], |
| outputs=[courses_state, course_dropdown, translate_status], |
| ).then( |
| fn=update_dropdown, |
| inputs=courses_state, |
| outputs=course_dropdown, |
| ) |
|
|
| help_btn.click( |
| fn=toggle_help, |
| inputs=help_visible, |
| outputs=[help_visible, help_text], |
| ) |
|
|
| if __name__ == "__main__": |
| port = int(os.environ.get("PORT", "7860")) |
| demo.launch(server_name="0.0.0.0", server_port=port) |
|
|