""" app.py — Dark Moon Study Report (Hugging Face Spaces Edition) HF Spaces 전용: pyaudiowpatch/Live 기능 제외 포트: 7860 (HF 기본값) """ import logging import os import re import tempfile from pathlib import Path from datetime import datetime import gradio as gr logging.basicConfig(level=logging.INFO) logger = logging.getLogger("study_hf") # ── 공유 유틸 ──────────────────────────────────────────────── def _extract_text_from_srt(path: str) -> str: content = Path(path).read_text(encoding="utf-8", errors="replace") content = re.sub(r"^\d+\s*$", "", content, flags=re.MULTILINE) content = re.sub(r"\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}", "", content) content = re.sub(r"<[^>]+>", "", content) return " ".join(content.split()) def _extract_youtube_id(url: str) -> str | None: """Extract YouTube video ID from various URL formats.""" patterns = [ r"(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})", ] for p in patterns: m = re.search(p, url) if m: return m.group(1) return None # ── Gemini 분석 ────────────────────────────────────────────── _ws_item = lambda props: {"type": "array", "items": {"type": "object", "properties": props}} _SCHEMA = { "type": "object", "properties": { "title": {"type": "string"}, "subject": {"type": "string"}, "content_type": {"type": "string"}, # english_lecture | korean_lecture | tech_tutorial | documentary | general "summary": { "type": "object", "properties": { "intro": {"type": "string"}, "main_points": _ws_item({ "point": {"type": "string"}, "details": {"type": "array", "items": {"type": "string"}}, }), }, }, "mermaid": {"type": "string"}, "keywords": _ws_item({"term": {"type": "string"}, "definition": {"type": "string"}}), "questions": _ws_item({"q": {"type": "string"}, "a": {"type": "string"}, "hint": {"type": "string"}}), "timeline": _ws_item({"topic": {"type": "string"}, "description": {"type": "string"}}), # ── 워크시트 전용 필드 ────────────────────────── "worksheet": { "type": "object", "properties": { "pre_viewing": _ws_item({"question": {"type": "string"}}), "vocabulary": _ws_item({ "word": {"type": "string"}, "definition": {"type": "string"}, "example": {"type": "string"}, }), "fill_blanks": _ws_item({"sentence": {"type": "string"}, "answer": {"type": "string"}}), "comprehension": _ws_item({"q": {"type": "string"}, "a": {"type": "string"}, "type": {"type": "string"}}), "discussion": _ws_item({"question": {"type": "string"}}), "self_check": _ws_item({"item": {"type": "string"}}), }, }, }, "required": ["title", "subject", "content_type", "summary", "mermaid", "keywords", "questions", "timeline", "worksheet"], } PROMPT = """You are an expert educator and learning materials designer. Analyze the lecture transcript below and produce a structured study report. OUTPUT LANGUAGE: {lang} All text fields (title, subject, intro, main_points, keywords definitions, questions, timeline, worksheet) MUST be written in {lang}. 【Core Rules】 - title: Lecture title (max 20 chars) - subject: Subject/field (max 10 chars) - content_type: one of english_lecture | korean_lecture | tech_tutorial | documentary | general - summary.intro: Single sentence — the ONE most important takeaway - summary.main_points: EXACTLY 3 key points. Each point must be a sharp, actionable insight (NOT a list of facts). Each point has 2 supporting details max. Be concise and essential, avoid redundancy. - keywords: 8 core terms — the word on front, a SHORT clear definition (max 20 words) on back - questions: 5 exam-quality questions (q, hint, a) - timeline: 4~5 lecture flow steps - worksheet fields: pre_viewing (2q), vocabulary (8 terms), fill_blanks (5), comprehension (5), discussion (2q), self_check (4 items) Transcript: {text} """ def _analyze(text: str, api_key: str, lang: str = "한국어") -> dict: import json import google.generativeai as genai genai.configure(api_key=api_key.strip()) model = genai.GenerativeModel( "gemini-2.0-flash", generation_config=genai.GenerationConfig( response_mime_type="application/json", response_schema=_SCHEMA, ), ) trimmed = text[:12000] + ("\n[생략]" if len(text) > 12000 else "") response = model.generate_content(PROMPT.format(text=trimmed, lang=lang)) return json.loads(response.text) # 스키마 적용으로 항상 유효한 JSON # ── HTML 리포트 렌더러 ──────────────────────────────────────── def _render(data: dict, source_url: str = "") -> str: title = data.get("title", "강의 정리") subject = data.get("subject", "일반") summary = data.get("summary", {}) keywords = data.get("keywords", []) questions= data.get("questions", []) timeline = data.get("timeline", []) date_str = datetime.now().strftime("%Y년 %m월 %d일") intro = f'

{summary.get("intro","")}

' points = "" for i, mp in enumerate(summary.get("main_points", []), 1): details = "".join(f"
  • {d}
  • " for d in mp.get("details", [])) points += f"""
    {i}{mp.get('point','')}
    """ cards = "" for kw in keywords: cards += f"""
    {kw.get('term','')}클릭 ↓
    {kw.get('definition','')}
    """ qs = "" for i, q in enumerate(questions, 1): qs += f"""
    Q{i}{q.get('q','')}
    """ tl = "" for t in timeline: tl += f"""
    {t.get('topic','')}
    {t.get('description','')}
    """ src = f'
    📎 {source_url}
    ' if source_url else "" # 키워드로 개념 버블 그리드 생성 (Mermaid 대체) bubble_items = "" colors = ["#e3f2fd","#fce4ec","#e8f5e9","#fff3e0","#f3e5f5","#e0f2f1","#fff8e1","#f1f8e9"] border_colors = ["#1565c0","#c62828","#2e7d32","#e65100","#6a1b9a","#00695c","#f57f17","#558b2f"] for idx, kw in enumerate(keywords[:8]): bg = colors[idx % len(colors)] bd = border_colors[idx % len(border_colors)] bubble_items += f'
    {kw.get("term","")}
    {kw.get("definition","")[:80]}
    ' concept_grid = f'
    {bubble_items}
    ' if bubble_items else '

    키워드 데이터 없음

    ' return f""" {title}
    {subject} 스터디 노트

    {title}

    🌕 Dark Moon Study Report  |  {date_str}
    📋핵심 요점 정리
    {intro}{points}
    🗺️핵심 개념 맵
    {concept_grid}
    핵심 암기 카드 (클릭)
    {cards}
    📝예상 시험 문제
    {qs}
    ⏱️강의 타임라인
    {tl}
    {src}
    """ # ── 워크시트 렌더러 ─────────────────────────────────────────── _WS_CSS = """ html,body{color-scheme:light;font-family:'Noto Sans KR',sans-serif;background:#fff!important;color:#1c1c1e!important;margin:0;padding:0;line-height:1.7} .ws-wrap{max-width:800px;margin:0 auto;padding:24px 20px} .ws-hero{background:linear-gradient(135deg,#1a237e,#3949ab);color:#fff;padding:28px 24px;border-radius:12px;margin-bottom:24px} .ws-hero h1{font-size:1.4rem;font-weight:900;margin:0 0 6px;color:#fff!important} .ws-meta{font-size:12px;opacity:.8} .ws-badge{display:inline-block;background:rgba(255,255,255,.2);border-radius:12px;padding:2px 10px;font-size:11px;margin-bottom:10px} .section{background:#f8f9fa;border-radius:10px;padding:18px 20px;margin-bottom:16px} .section h2{font-size:14px;font-weight:700;color:#1a237e!important;margin:0 0 12px;display:flex;align-items:center;gap:8px} .vocab-table{width:100%;border-collapse:collapse;font-size:13px} .vocab-table th{background:#1a237e;color:#fff!important;padding:7px 10px;text-align:left} .vocab-table td{padding:7px 10px;border-bottom:1px solid #e0e0e0;color:#1c1c1e!important} .vocab-table tr:nth-child(even) td{background:#f0f4ff} .blank-item{padding:8px 12px;border-left:3px solid #00bcd4;margin-bottom:8px;background:#fff;color:#1c1c1e!important;font-size:13px;border-radius:0 6px 6px 0} .q-item{padding:8px 12px;margin-bottom:8px;border:1px solid #e0e0e0;border-radius:6px;font-size:13px;color:#1c1c1e!important} .q-num{font-weight:700;color:#7b1fa2!important;margin-right:6px} .ans{color:#2e7d32!important;font-size:12px;margin-top:4px;padding:4px 8px;background:#e8f5e9;border-radius:4px} .pre-q{padding:8px 12px;background:#e3f2fd;border-radius:6px;margin-bottom:8px;font-size:13px;color:#1c1c1e!important} .disc-q{padding:8px 12px;background:#fce4ec;border-radius:6px;margin-bottom:8px;font-size:13px;color:#1c1c1e!important} .check-item{padding:6px 0;font-size:13px;display:flex;align-items:center;gap:8px;color:#1c1c1e!important} .check-box{width:16px;height:16px;border:2px solid #1a237e;border-radius:3px;flex-shrink:0} .footer{text-align:center;padding:16px;font-size:11px;color:#bdbdbd} .answer-key{background:#fff3e0;border-left:4px solid #f9a825;padding:4px 8px;font-size:12px;color:#e65100!important;border-radius:0 4px 4px 0;margin-top:4px} @media print{.ws-hero{-webkit-print-color-adjust:exact;print-color-adjust:exact}} """ _TYPE_LABELS = { "english_lecture": "🇬🇧 English Lecture", "korean_lecture": "📚 한국어 강의", "tech_tutorial": "💻 Tech Tutorial", "documentary": "🎬 Documentary", "general": "📹 Video Lesson", } def _render_worksheet(data: dict) -> str: """학생용 워크시트 (정답 미포함)""" ws = data.get("worksheet", {}) title = data.get("title", "학습 워크시트") ct = data.get("content_type", "general") date = datetime.now().strftime("%Y-%m-%d") badge = _TYPE_LABELS.get(ct, "📹 Video Lesson") pre = "".join(f'
    💭 {q.get("question","")}
    ' for q in ws.get("pre_viewing", [])) vocab_rows = "".join( f'{v.get("word","")}{v.get("definition","")}' f'{v.get("example","")}' for v in ws.get("vocabulary", []) ) blanks = "".join(f'
    {i+1}. {fb.get("sentence","")}
    ' for i, fb in enumerate(ws.get("fill_blanks", []))) comps = "".join(f'
    Q{i+1}{c.get("q","")}
    ' for i, c in enumerate(ws.get("comprehension", []))) discs = "".join(f'
    🗣️ {d.get("question","")}
    ' for d in ws.get("discussion", [])) checks = "".join( f'
    {s.get("item","")}
    ' for s in ws.get("self_check", []) ) return f"""
    {badge}

    {title} — 학습 워크시트

    🌕 Dark Moon Lesson Builder  |  {date}  |  Student Copy

    🔍 시청 전 활동

    {pre or "

    -

    "}

    📖 핵심 어휘

    {vocab_rows}
    단어/용어의미예문/예시

    ✏️ 시청 중 — 빈칸 채우기

    {blanks or "

    -

    "}

    🧠 이해 확인 문제

    {comps or "

    -

    "}

    💬 토론 질문

    {discs or "

    -

    "}

    ✅ 자기 평가

    {checks or "

    -

    "}
    """ def _render_teacher(data: dict) -> str: """교사용 가이드 (정답 포함)""" ws = data.get("worksheet", {}) title = data.get("title", "교사 가이드") ct = data.get("content_type", "general") date = datetime.now().strftime("%Y-%m-%d") badge = _TYPE_LABELS.get(ct, "📹 Video Lesson") pre = "".join(f'
    💭 {q.get("question","")}
    ' for q in ws.get("pre_viewing", [])) vocab_rows = "".join( f'{v.get("word","")}{v.get("definition","")}' f'{v.get("example","")}' for v in ws.get("vocabulary", []) ) blanks = "".join( f'
    {i+1}. {fb.get("sentence","")}' f'
    🔑 {fb.get("answer","")}
    ' for i, fb in enumerate(ws.get("fill_blanks", [])) ) comps = "".join( f'
    Q{i+1}{c.get("q","")}' f'
    ✅ {c.get("a","")}
    ' for i, c in enumerate(ws.get("comprehension", [])) ) discs = "".join(f'
    🗣️ {d.get("question","")}
    ' for d in ws.get("discussion", [])) checks = "".join( f'
    {s.get("item","")}
    ' for s in ws.get("self_check", []) ) study_q = "".join( f'
    Q{i+1}{q.get("q","")}' f'
    ✅ {q.get("a","")}
    ' f'
    💡 힌트: {q.get("hint","")}
    ' for i, q in enumerate(data.get("questions", [])) ) return f"""
    {badge}

    {title} — 교사 가이드

    🌕 Dark Moon Lesson Builder  |  {date}  |  Teacher Copy
    🗝️ 교사용 — 정답 포함 버전. 학생에게 배포하지 마세요.

    🔍 시청 전 활동

    {pre or "

    -

    "}

    📖 핵심 어휘 (정답)

    {vocab_rows}
    단어/용어의미예문/예시

    ✏️ 빈칸 채우기 (정답)

    {blanks or "

    -

    "}

    🧠 이해 문제 (정답)

    {comps or "

    -

    "}

    💬 토론 질문

    {discs or "

    -

    "}

    ✅ 자기 평가

    {checks or "

    -

    "}

    📝 추가 예상 문제 정답

    {study_q or "

    -

    "}
    """ # ── 메인 핸들러 ────────────────────────────────────────────── def run_analysis( srt_file, text_in: str, gemini_key: str, lang: str, ) -> tuple: if not gemini_key or not gemini_key.strip(): return "❌ Gemini API Key를 입력하세요.\naistudio.google.com에서 무료 발급.", "", None, None, None text = "" # ── 1. 텍스트 소스 결정 ────────────────────────────── if srt_file is not None: try: srt_path = srt_file if isinstance(srt_file, str) else srt_file.name text = _extract_text_from_srt(srt_path) except Exception as e: return f"❌ 파일 읽기 실패: {e}", "", None, None, None elif text_in and text_in.strip(): text = text_in.strip() else: return "❌ SRT 파일을 업로드하거나 텍스트를 붙여넣기 하세요.", "", None, None, None if not text.strip(): return "❌ 텍스트가 비어 있습니다.", "", None, None, None # ── 2. Gemini 분석 ──────────────────────────────────── logger.info("🤖 Gemini AI 분석 중...") try: data = _analyze(text, gemini_key, lang) except Exception as e: return f"❌ Gemini 분석 실패: {str(e)[:150]}\n(API Key와 네트워크를 확인하세요)", "", None, None, None # ── 3. 파일 생성 (학습 노트 + 워크시트 + 교사 가이드) ──── logger.info("✨ 파일 생성 중...") ts = datetime.now().strftime("%Y%m%d_%H%M%S") title_raw = str(data.get("title") or "report") safe = re.sub(r'[^\w\-_가-힣]', '_', title_raw)[:30] tmp_dir = Path(tempfile.gettempdir()) # 학습 노트 HTML notes_html = _render(data, "") notes_path = tmp_dir / f"{safe}_{ts}_notes.html" notes_path.write_text(notes_html, encoding="utf-8") # 학생 워크시트 ws_html = _render_worksheet(data) ws_path = tmp_dir / f"{safe}_{ts}_worksheet.html" ws_path.write_text(ws_html, encoding="utf-8") # 교사 가이드 tch_html = _render_teacher(data) tch_path = tmp_dir / f"{safe}_{ts}_teacher.html" tch_path.write_text(tch_html, encoding="utf-8") # Markdown 노트 md_lines = [ f"# {data.get('title','')}", f"> {data.get('subject','')} ({data.get('content_type','')}) | {datetime.now().strftime('%Y-%m-%d')}", "", f"## 요약\n{data.get('summary',{}).get('intro','')}", "", "## 핵심 요점", ] for i, mp in enumerate(data.get("summary",{}).get("main_points",[]), 1): md_lines.append(f"### {i}. {mp.get('point','')}") for d in mp.get("details",[]): md_lines.append(f"- {d}") md_lines += ["", "## 핵심 용어", ""] for kw in data.get("keywords",[]): md_lines.append(f"- **{kw.get('term','')}**: {kw.get('definition','')}") md_lines += ["", "## 예상 문제", ""] for i, q in enumerate(data.get("questions",[]), 1): md_lines += [f"**Q{i}.** {q.get('q','')}", f"> 힌트: {q.get('hint','')}", f"> 정답: {q.get('a','')}", ""] md_str = "\n".join(md_lines) md_path = notes_path.with_suffix(".md") md_path.write_text(md_str, encoding="utf-8") ct = data.get("content_type", "general") logger.info("✅ 완료!") status = ( f"✅ 분석 완료!\n📌 제목: {data.get('title','')}\n" f"📚 과목: {data.get('subject','')} [{ct}]\n" f"📄 학습 노트 + 학생 워크시트 + 교사 가이드 생성됨" ) return status, notes_html, str(notes_path), str(ws_path), str(tch_path) # ── Gradio UI ──────────────────────────────────────────────── CSS = """ .title{font-size:2rem;font-weight:900;color:#1a237e;text-align:center;padding:12px 0 2px} .sub{text-align:center;color:#636e72;font-size:14px;margin-bottom:8px} """ HOW_TO_MD = """ ### 📋 자막 얻는 방법 **YouTube 영상** 1. 영상 페이지 → `...` 더보기 → **자막 열기** 2. 자막 텍스트 전체 복사 → 아래 **텍스트 붙여넣기** 탭에 붙여넣기 또는 [DownSub.com](https://downsub.com) / [savesubs.com](https://savesubs.com) 에서 SRT 다운로드 후 파일 업로드 **로컬 영상 / 시스템 소리 캡처** → [Pro 버전 구매](https://soundfury37.gumroad.com/l/blvcc) 필요 """ def build_ui(): with gr.Blocks(title="🌕 Dark Moon Lesson Builder", css=CSS) as demo: gr.HTML('

    🌕 Dark Moon Lesson Builder

    ') gr.HTML('

    강의 자막 → Gemini AI → 학습 노트 + 워크시트 + 교사 가이드 자동 생성

    ') with gr.Row(): # 입력 패널 with gr.Column(scale=1, min_width=340): srt_in = gr.File( label="📁 SRT / TXT 파일 업로드", file_types=[".srt", ".txt"], ) text_in = gr.Textbox( label="📝 또는 텍스트 직접 붙여넣기 (파일 우선)", placeholder="YouTube 자막, 강의 스크립트 등... (파일 업로드 시 이 칸은 무시됩니다)", lines=6, ) gr.Markdown(HOW_TO_MD) lang_in = gr.Dropdown( label="🌐 출력 언어", choices=["한국어", "English", "日本語", "中文"], value="한국어", info="학습 노트 출력 언어 선택", ) key_in = gr.Textbox( label="🔑 Gemini API Key", type="password", placeholder="AIza...", info="무료 발급: aistudio.google.com", ) run_btn = gr.Button("🚀 분석 시작", variant="primary", size="lg") status_out = gr.Textbox(label="상태", lines=4, interactive=False) gr.Markdown("**📥 다운로드**") with gr.Row(): dl_notes = gr.File(label="학습 노트") dl_ws = gr.File(label="학생 워크시트") dl_tch = gr.File(label="교사 가이드") # 학습 노트 미리보기 with gr.Column(scale=2): gr.Markdown("### 📊 학습 노트 미리보기") notes_out = gr.HTML( '
    ' '👈 자막/텍스트 입력 후 🚀 분석 시작 클릭
    ' ) run_btn.click( run_analysis, inputs=[srt_in, text_in, key_in, lang_in], outputs=[status_out, notes_out, dl_notes, dl_ws, dl_tch], api_name=False, ) gr.Markdown( "---\n" "**🌕 Dark Moon Lesson Builder** — by 달의이성  |  " "[Gemini API 무료 발급](https://aistudio.google.com)  |  " "[시스템 오디오 Pro 버전](https://soundfury37.gumroad.com/l/blvcc)  |  " "데이터는 서버에 저장되지 않습니다." ) return demo if __name__ == "__main__": demo = build_ui() demo.launch(server_name="0.0.0.0", server_port=7860)