"""
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""""""
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','')}▼
💡 {q.get('hint','')}
✅ {q.get('a','')}
"""
tl = ""
for t in timeline:
tl += f"""
{t.get('topic','')}
{t.get('description','')}
"""
src = f'' 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}
{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''
for s in ws.get("self_check", [])
)
return f"""
{badge}
{title} — 학습 워크시트
🌕 Dark Moon Lesson Builder | {date} | Student Copy
📖 핵심 어휘
| 단어/용어 | 의미 | 예문/예시 |
{vocab_rows}
✏️ 시청 중 — 빈칸 채우기
{blanks 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''
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
🗝️ 교사용 — 정답 포함 버전. 학생에게 배포하지 마세요.
📖 핵심 어휘 (정답)
| 단어/용어 | 의미 | 예문/예시 |
{vocab_rows}
✏️ 빈칸 채우기 (정답)
{blanks or "
-
"}
🧠 이해 문제 (정답)
{comps 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)