Spaces:
Sleeping
Sleeping
| """ | |
| 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 | {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 | {date} | 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 | {date} | 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 λ¬μμ΄μ± | " | |
| "[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) | |