reasonofmoon's picture
[deploy] Update app.py
6f27df4 verified
Raw
History Blame Contribute Delete
31.9 kB
"""
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'<p class="intro">{summary.get("intro","")}</p>'
points = ""
for i, mp in enumerate(summary.get("main_points", []), 1):
details = "".join(f"<li>{d}</li>" for d in mp.get("details", []))
points += f"""<div class="point-card">
<div class="point-hd"><span class="pn">{i}</span><span class="pt">{mp.get('point','')}</span></div>
<ul class="dl">{details}</ul></div>"""
cards = ""
for kw in keywords:
cards += f"""<div class="flip-card">
<div class="flip-inner">
<div class="flip-front"><span class="term">{kw.get('term','')}</span><span class="ch">클릭 ↓</span></div>
<div class="flip-back"><span class="defi">{kw.get('definition','')}</span></div>
</div></div>"""
qs = ""
for i, q in enumerate(questions, 1):
qs += f"""<div class="qcard">
<div class="qhd" onclick="tog(this)">
<span class="qn">Q{i}</span><span class="qt">{q.get('q','')}</span><span class="arr">β–Ό</span>
</div>
<div class="qans" style="display:none">
<div class="hint">πŸ’‘ {q.get('hint','')}</div>
<div class="ans">βœ… {q.get('a','')}</div>
</div></div>"""
tl = ""
for t in timeline:
tl += f"""<div class="tli">
<div class="dot"></div>
<div class="tlc"><div class="tlto">{t.get('topic','')}</div>
<div class="tlds">{t.get('description','')}</div></div></div>"""
src = f'<div class="src">πŸ“Ž <a href="{source_url}" target="_blank">{source_url}</a></div>' 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'<div style="background:{bg};border:2px solid {bd};border-radius:12px;padding:14px 16px;text-align:center"><div style="font-weight:700;font-size:13px;color:{bd};margin-bottom:6px">{kw.get("term","")}</div><div style="font-size:12px;color:#333;line-height:1.5">{kw.get("definition","")[:80]}</div></div>'
concept_grid = f'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;padding:18px 22px">{bubble_items}</div>' if bubble_items else '<p style="padding:18px;color:#999">ν‚€μ›Œλ“œ 데이터 μ—†μŒ</p>'
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title}</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap" rel="stylesheet">
<style>
:root{{color-scheme:light;--navy:#1a237e;--navy2:#283593;--mint:#00bcd4;--mint-l:#e0f7fa;--yell:#fff9c4;--ydk:#f9a825;--purp:#f3e5f5;--pdk:#7b1fa2;--tx:#1c1c1e;--txl:#636e72;--r:14px;--sh:0 4px 20px rgba(0,0,0,.08)}}
*{{box-sizing:border-box;margin:0;padding:0}}
html,body{{font-family:'Noto Sans KR',sans-serif;background:#f4f6f9!important;color:#1c1c1e!important;line-height:1.7}}
.hero{{background:#ffffff;color:#1c1c1e;padding:48px 32px 40px;text-align:center;border-bottom:2px solid #e0e0e0}}
.badge{{display:inline-block;background:rgba(26,35,126,.1);border:1px solid rgba(26,35,126,.3);border-radius:20px;padding:4px 16px;font-size:12px;font-weight:500;margin-bottom:16px;letter-spacing:1px;color:var(--navy)}}
.hero h1{{font-size:2rem;font-weight:900;margin-bottom:8px;color:#1a237e!important}}
.meta{{font-size:13px;opacity:.7;margin-top:6px;color:#1c1c1e!important}}
.wrap{{max-width:900px;margin:0 auto;padding:32px 16px;display:flex;flex-direction:column;gap:24px}}
.sec{{background:#fff;border-radius:var(--r);box-shadow:var(--sh);overflow:hidden}}
.shd{{display:flex;align-items:center;gap:12px;padding:18px 22px 14px;border-bottom:1px solid #f0f0f0}}
.si{{font-size:20px}}.st{{font-size:17px;font-weight:700;color:var(--navy)}}
.sb{{padding:18px 22px}}
.intro{{color:var(--txl);font-size:15px;padding:12px 16px;background:var(--mint-l);border-left:4px solid var(--mint);border-radius:8px;margin-bottom:14px}}
.point-card{{border:1px solid #f0f0f0;border-radius:10px;margin-bottom:10px;overflow:hidden}}
.point-hd{{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#fafafa}}
.pn{{width:26px;height:26px;border-radius:50%;background:#1a237e!important;color:#fff!important;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0}}
.pt{{font-weight:700;font-size:14px;color:#1a237e!important}}
.dl{{padding:10px 14px 10px 50px;list-style:none}}
.dl li{{position:relative;padding-left:14px;color:var(--txl);font-size:13px;margin-bottom:5px}}
.dl li::before{{content:'β€’';position:absolute;left:0;color:var(--mint);font-weight:700}}
.mw{{padding:22px;overflow-x:auto;text-align:center}}
.cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px;padding:18px 22px}}
.flip-card{{perspective:800px;min-height:140px;cursor:pointer}}
.flip-inner{{position:relative;width:100%;min-height:140px;transform-style:preserve-3d;transition:transform .5s}}
.flip-card:hover .flip-inner,.flip-card.on .flip-inner{{transform:rotateY(180deg)}}
.flip-front,.flip-back{{position:absolute;top:0;left:0;width:100%;min-height:140px;border-radius:10px;backface-visibility:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:14px;text-align:center;overflow:hidden;word-break:break-word;overflow-wrap:break-word}}
.flip-front{{background:var(--yell);border:2px solid var(--ydk)}}
.flip-back{{background:#fff;color:#1c1c1e!important;transform:rotateY(180deg);border:2px solid #e0e0e0}}
.term{{font-size:14px;font-weight:700;color:#1a237e!important;margin-bottom:6px;word-break:break-word}}
.ch{{font-size:10px;color:var(--txl)}}
.defi{{font-size:11px;line-height:1.5;color:#1c1c1e!important;word-break:break-word;overflow-wrap:break-word}}
.qcard{{border:1px solid #f0f0f0;border-radius:10px;margin-bottom:10px;overflow:hidden}}
.qhd{{display:flex;align-items:flex-start;gap:10px;padding:14px;cursor:pointer;background:var(--purp);transition:background .2s}}
.qhd:hover{{background:#e8d5f5}}
.qn{{background:var(--pdk);color:#fff;border-radius:6px;padding:2px 7px;font-size:11px;font-weight:700;flex-shrink:0}}
.qt{{flex:1;font-size:13px;font-weight:500;color:var(--navy)}}
.arr{{color:var(--txl);font-size:11px;flex-shrink:0}}
.qans{{padding:14px;background:#fff}}
.hint{{font-size:13px;color:var(--txl);padding:7px 10px;background:#fafafa;border-radius:6px;margin-bottom:8px}}
.ans{{font-size:13px;color:#2e7d32;font-weight:600;padding:7px 10px;background:#e8f5e9;border-radius:6px}}
.tlw{{padding:18px 22px}}
.tli{{display:flex;gap:14px;margin-bottom:18px;position:relative}}
.tli:not(:last-child)::after{{content:'';position:absolute;left:10px;top:22px;width:2px;height:calc(100% + 2px);background:#e0e0e0}}
.dot{{width:22px;height:22px;border-radius:50%;background:var(--mint);border:3px solid #fff;box-shadow:0 0 0 2px var(--mint);flex-shrink:0;margin-top:2px}}
.tlc{{flex:1}}
.tlto{{font-weight:700;font-size:13px;color:var(--navy);margin-bottom:3px}}
.tlds{{font-size:12px;color:var(--txl)}}
.src{{text-align:center;font-size:12px;color:var(--txl);padding:8px 0}}
.src a{{color:var(--mint);text-decoration:none}}
.footer{{text-align:center;padding:22px;font-size:12px;color:#bdbdbd}}
@media print{{body{{background:#fff}}.hero{{-webkit-print-color-adjust:exact;print-color-adjust:exact}}}}
</style>
</head>
<body>
<div class="hero">
<div class="badge">{subject} μŠ€ν„°λ”” λ…ΈνŠΈ</div>
<h1>{title}</h1>
<div class="meta">πŸŒ• Dark Moon Study Report &nbsp;|&nbsp; {date_str}</div>
</div>
<div class="wrap">
<div class="sec">
<div class="shd"><span class="si">πŸ“‹</span><span class="st">핡심 μš”μ  정리</span></div>
<div class="sb">{intro}{points}</div>
</div>
<div class="sec">
<div class="shd"><span class="si">πŸ—ΊοΈ</span><span class="st">핡심 κ°œλ… λ§΅</span></div>
{concept_grid}
</div>
<div class="sec">
<div class="shd"><span class="si">⚑</span><span class="st">핡심 μ•”κΈ° μΉ΄λ“œ (클릭)</span></div>
<div class="cards">{cards}</div>
</div>
<div class="sec">
<div class="shd"><span class="si">πŸ“</span><span class="st">μ˜ˆμƒ μ‹œν—˜ 문제</span></div>
<div class="sb">{qs}</div>
</div>
<div class="sec">
<div class="shd"><span class="si">⏱️</span><span class="st">κ°•μ˜ νƒ€μž„λΌμΈ</span></div>
<div class="tlw">{tl}</div>
</div>
{src}
</div>
<div class="footer">πŸŒ• Dark Moon Study Report β€” by λ‹¬μ˜μ΄μ„±</div>
<script>
function tog(el){{const a=el.nextElementSibling,ar=el.querySelector('.arr');if(a.style.display==='none'){{a.style.display='block';ar.textContent='β–²'}}else{{a.style.display='none';ar.textContent='β–Ό'}}}}
document.querySelectorAll('.flip-card').forEach(c=>c.addEventListener('click',()=>c.classList.toggle('on')));
</script>
</body>
</html>"""
# ── μ›Œν¬μ‹œνŠΈ λ Œλ”λŸ¬ ───────────────────────────────────────────
_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'<div class="pre-q">πŸ’­ {q.get("question","")}</div>' for q in ws.get("pre_viewing", []))
vocab_rows = "".join(
f'<tr><td><b>{v.get("word","")}</b></td><td>{v.get("definition","")}</td>'
f'<td style="color:#636e72;font-size:12px">{v.get("example","")}</td></tr>'
for v in ws.get("vocabulary", [])
)
blanks = "".join(f'<div class="blank-item">{i+1}. {fb.get("sentence","")}</div>'
for i, fb in enumerate(ws.get("fill_blanks", [])))
comps = "".join(f'<div class="q-item"><span class="q-num">Q{i+1}</span>{c.get("q","")}</div>'
for i, c in enumerate(ws.get("comprehension", [])))
discs = "".join(f'<div class="disc-q">πŸ—£οΈ {d.get("question","")}</div>'
for d in ws.get("discussion", []))
checks = "".join(
f'<div class="check-item"><div class="check-box"></div>{s.get("item","")}</div>'
for s in ws.get("self_check", [])
)
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet">
<style>{_WS_CSS}</style></head><body>
<div class="ws-wrap">
<div class="ws-hero"><div class="ws-badge">{badge}</div>
<h1>{title} β€” ν•™μŠ΅ μ›Œν¬μ‹œνŠΈ</h1>
<div class="ws-meta">πŸŒ• Dark Moon Lesson Builder &nbsp;|&nbsp; {date} &nbsp;|&nbsp; Student Copy</div>
</div>
<div class="section"><h2>πŸ” μ‹œμ²­ μ „ ν™œλ™</h2>{pre or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>πŸ“– 핡심 μ–΄νœ˜</h2>
<table class="vocab-table"><thead><tr><th>단어/μš©μ–΄</th><th>의미</th><th>예문/μ˜ˆμ‹œ</th></tr></thead>
<tbody>{vocab_rows}</tbody></table></div>
<div class="section"><h2>✏️ μ‹œμ²­ 쀑 β€” 빈칸 μ±„μš°κΈ°</h2>{blanks or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>🧠 이해 확인 문제</h2>{comps or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>πŸ’¬ ν† λ‘  질문</h2>{discs or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>βœ… 자기 평가</h2>{checks or "<p style='color:#999'>-</p>"}</div>
<div class="footer">πŸŒ• Dark Moon Lesson Builder β€” by λ‹¬μ˜μ΄μ„± | Student Copy</div>
</div></body></html>"""
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'<div class="pre-q">πŸ’­ {q.get("question","")}</div>' for q in ws.get("pre_viewing", []))
vocab_rows = "".join(
f'<tr><td><b>{v.get("word","")}</b></td><td>{v.get("definition","")}</td>'
f'<td style="color:#636e72;font-size:12px">{v.get("example","")}</td></tr>'
for v in ws.get("vocabulary", [])
)
blanks = "".join(
f'<div class="blank-item">{i+1}. {fb.get("sentence","")}'
f'<div class="answer-key">πŸ”‘ {fb.get("answer","")}</div></div>'
for i, fb in enumerate(ws.get("fill_blanks", []))
)
comps = "".join(
f'<div class="q-item"><span class="q-num">Q{i+1}</span>{c.get("q","")}'
f'<div class="ans">βœ… {c.get("a","")}</div></div>'
for i, c in enumerate(ws.get("comprehension", []))
)
discs = "".join(f'<div class="disc-q">πŸ—£οΈ {d.get("question","")}</div>' for d in ws.get("discussion", []))
checks = "".join(
f'<div class="check-item"><div class="check-box"></div>{s.get("item","")}</div>'
for s in ws.get("self_check", [])
)
study_q = "".join(
f'<div class="q-item"><span class="q-num">Q{i+1}</span>{q.get("q","")}'
f'<div class="ans">βœ… {q.get("a","")}</div>'
f'<div style="font-size:11px;color:#636e72;margin-top:2px">πŸ’‘ 힌트: {q.get("hint","")}</div></div>'
for i, q in enumerate(data.get("questions", []))
)
return f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet">
<style>{_WS_CSS}
.teacher-banner{{background:#e8f5e9;border:2px solid #2e7d32;border-radius:8px;padding:10px 16px;margin-bottom:16px;font-size:13px;color:#1b5e20!important;font-weight:700}}
</style></head><body>
<div class="ws-wrap">
<div class="ws-hero"><div class="ws-badge">{badge}</div>
<h1>{title} β€” ꡐ사 κ°€μ΄λ“œ</h1>
<div class="ws-meta">πŸŒ• Dark Moon Lesson Builder &nbsp;|&nbsp; {date} &nbsp;|&nbsp; Teacher Copy</div>
</div>
<div class="teacher-banner">πŸ—οΈ κ΅μ‚¬μš© β€” μ •λ‹΅ 포함 버전. ν•™μƒμ—κ²Œ λ°°ν¬ν•˜μ§€ λ§ˆμ„Έμš”.</div>
<div class="section"><h2>πŸ” μ‹œμ²­ μ „ ν™œλ™</h2>{pre or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>πŸ“– 핡심 μ–΄νœ˜ (μ •λ‹΅)</h2>
<table class="vocab-table"><thead><tr><th>단어/μš©μ–΄</th><th>의미</th><th>예문/μ˜ˆμ‹œ</th></tr></thead>
<tbody>{vocab_rows}</tbody></table></div>
<div class="section"><h2>✏️ 빈칸 μ±„μš°κΈ° (μ •λ‹΅)</h2>{blanks or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>🧠 이해 문제 (μ •λ‹΅)</h2>{comps or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>πŸ’¬ ν† λ‘  질문</h2>{discs or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>βœ… 자기 평가</h2>{checks or "<p style='color:#999'>-</p>"}</div>
<div class="section"><h2>πŸ“ μΆ”κ°€ μ˜ˆμƒ 문제 μ •λ‹΅</h2>{study_q or "<p style='color:#999'>-</p>"}</div>
<div class="footer">πŸŒ• Dark Moon Lesson Builder β€” by λ‹¬μ˜μ΄μ„± | CONFIDENTIAL</div>
</div></body></html>"""
# ── 메인 ν•Έλ“€λŸ¬ ──────────────────────────────────────────────
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('<p class="title">πŸŒ• Dark Moon Lesson Builder</p>')
gr.HTML('<p class="sub">κ°•μ˜ μžλ§‰ β†’ Gemini AI β†’ ν•™μŠ΅ λ…ΈνŠΈ + μ›Œν¬μ‹œνŠΈ + ꡐ사 κ°€μ΄λ“œ μžλ™ 생성</p>')
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(
'<div style="text-align:center;padding:80px;color:#bdbdbd;font-size:16px">'
'πŸ‘ˆ μžλ§‰/ν…μŠ€νŠΈ μž…λ ₯ ν›„ πŸš€ 뢄석 μ‹œμž‘ 클릭</div>'
)
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 λ‹¬μ˜μ΄μ„± &nbsp;|&nbsp; "
"[Gemini API 무료 λ°œκΈ‰](https://aistudio.google.com) &nbsp;|&nbsp; "
"[μ‹œμŠ€ν…œ μ˜€λ””μ˜€ Pro 버전](https://soundfury37.gumroad.com/l/blvcc) &nbsp;|&nbsp; "
"λ°μ΄ν„°λŠ” μ„œλ²„μ— μ €μž₯λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."
)
return demo
if __name__ == "__main__":
demo = build_ui()
demo.launch(server_name="0.0.0.0", server_port=7860)