Spaces:
Running
Running
| """Artifacts tab: Summary, Podcast, and Quiz generation.""" | |
| import os | |
| import tempfile | |
| from datetime import datetime | |
| from state import UserData, get_active_notebook, get_all_artifacts, get_latest_artifact | |
| def _save_artifact_to_temp_file(artifact) -> str | None: | |
| """Save artifact content to a temporary file. Returns the file path.""" | |
| try: | |
| if artifact.type == "podcast" and artifact.audio_path: | |
| if os.path.exists(artifact.audio_path): | |
| return artifact.audio_path | |
| else: | |
| safe_title = artifact.title.replace(" ", "_")[:50] | |
| suffix = '.html' if artifact.type == "quiz" else '.md' | |
| with tempfile.NamedTemporaryFile( | |
| mode='w', suffix=suffix, prefix=f'{safe_title}_', | |
| delete=False, encoding='utf-8' | |
| ) as f: | |
| f.write(artifact.content) | |
| return f.name | |
| except Exception as e: | |
| import logging | |
| logging.error(f"Failed to save artifact: {e}") | |
| return None | |
| def _format_time(iso_str: str) -> str: | |
| try: | |
| dt = datetime.fromisoformat(iso_str) | |
| return dt.strftime("%b %d at %H:%M") | |
| except (ValueError, KeyError): | |
| return "" | |
| def _render_artifact_content(artifact) -> str: | |
| """Render a single artifact as HTML with metadata.""" | |
| time_str = _format_time(artifact.created_at) | |
| html = ( | |
| f'<div style="padding:4px 0 8px 0; font-size:0.8rem; color:#707088;">' | |
| f'Generated {time_str}</div>' | |
| ) | |
| html += ( | |
| f'<div style="max-height:400px; overflow-y:auto; padding:16px; ' | |
| f'background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.08); ' | |
| f'border-radius:14px;">' | |
| ) | |
| html += f'<div class="artifact-content">{artifact.content}</div>' | |
| html += '</div>' | |
| if artifact.type == "podcast": | |
| if artifact.audio_path: | |
| html += ( | |
| f'<div style="margin-top:12px; padding:14px 16px; ' | |
| f'background:rgba(102,126,234,0.06); border:1px solid rgba(102,126,234,0.15); ' | |
| f'border-radius:10px;">' | |
| f'<div style="font-size:0.82rem; color:#8090d0; margin-bottom:8px;">π§ Podcast Audio</div>' | |
| f'<audio controls style="width:100%; border-radius:6px;">' | |
| f'<source src="/gradio_api/file={artifact.audio_path}" type="audio/mpeg">' | |
| f'<source src="/file={artifact.audio_path}" type="audio/mpeg">' | |
| f'Your browser does not support the audio element.' | |
| f'</audio>' | |
| f'</div>' | |
| ) | |
| else: | |
| html += ( | |
| '<div style="display:flex; align-items:center; gap:10px; padding:12px 16px; ' | |
| 'background:rgba(102,126,234,0.06); border:1px solid rgba(102,126,234,0.15); ' | |
| 'border-radius:10px; margin-top:8px;">' | |
| '<span style="font-size:1.3rem;">π</span>' | |
| '<span style="font-size:0.85rem; color:#8888aa;">Audio generation failed or TTS is unavailable.</span>' | |
| '</div>' | |
| ) | |
| return html | |
| def _render_history(artifacts: list, label: str) -> str: | |
| if len(artifacts) <= 1: | |
| return "" | |
| html = f'<details><summary style="cursor:pointer; color:#a0a0b8; font-size:0.85rem; margin-top:12px;">Previous {label} ({len(artifacts) - 1})</summary>' | |
| for a in artifacts[1:]: | |
| time_str = _format_time(a.created_at) | |
| html += f'<div style="margin-top:12px; padding:12px; border:1px solid rgba(255,255,255,0.06); border-radius:10px;">' | |
| html += f'<strong>{a.title}</strong> β {time_str}' | |
| html += f'<div style="max-height:200px; overflow-y:auto; margin-top:8px; font-size:0.85rem;">{a.content}</div>' | |
| html += '</div>' | |
| html += '</details>' | |
| return html | |
| # ββ No-sources gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_no_sources_gate(state: UserData) -> str: | |
| nb = get_active_notebook(state) | |
| if not nb or not nb.sources: | |
| return ( | |
| '<div class="empty-state">' | |
| '<div style="font-size:3rem; margin-bottom:16px;">π―</div>' | |
| '<h3>Add sources first</h3>' | |
| '<p>Upload documents in the <strong>Sources</strong> tab to unlock ' | |
| 'summary, quiz, and podcast generation.</p>' | |
| '</div>' | |
| ) | |
| return "" | |
| def has_sources(state: UserData) -> bool: | |
| nb = get_active_notebook(state) | |
| return nb is not None and len(nb.sources) > 0 | |
| # ββ Conversation Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_conv_summary_section(state: UserData) -> str: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return "" | |
| if not nb.messages: | |
| return "*No conversation yet. Start chatting in the **Chat** tab first.*" | |
| summaries = get_all_artifacts(nb, "conversation_summary") | |
| if not summaries: | |
| return "*Click Generate to create a conversation summary.*" | |
| latest = summaries[0] | |
| time_str = _format_time(latest.created_at) | |
| md = "*Generated " + time_str + "*\n\n---\n\n" + latest.content | |
| if len(summaries) > 1: | |
| prev = ", ".join(_format_time(a.created_at) for a in summaries[1:]) | |
| md += "\n\n---\n\n**Previous summaries:** " + prev | |
| return md | |
| def handle_gen_conv_summary(style: str, state: UserData) -> tuple[UserData, str]: | |
| nb = get_active_notebook(state) | |
| if not nb or not nb.messages: | |
| return state, render_conv_summary_section(state) | |
| from services.summary_service import generate_conversation_summary | |
| artifact = generate_conversation_summary(nb, style or "detailed") | |
| nb.artifacts.append(artifact) | |
| return state, render_conv_summary_section(state) | |
| # ββ Document Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_doc_summary_section(state: UserData) -> str: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return "" | |
| summaries = get_all_artifacts(nb, "document_summary") | |
| if not summaries: | |
| return "*Click Generate to create a document summary.*" | |
| latest = summaries[0] | |
| time_str = _format_time(latest.created_at) | |
| md = "*Generated " + time_str + "*\n\n---\n\n" + latest.content | |
| if len(summaries) > 1: | |
| prev = ", ".join(_format_time(a.created_at) for a in summaries[1:]) | |
| md += "\n\n---\n\n**Previous summaries:** " + prev | |
| return md | |
| def handle_gen_doc_summary(style: str, selected_sources: list[str] | None, state: UserData) -> tuple[UserData, str]: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return state, render_doc_summary_section(state) | |
| from services.summary_service import generate_document_summary | |
| source_ids = [s.id for s in nb.sources if s.filename in (selected_sources or [])] | |
| artifact = generate_document_summary(nb, style or "detailed", source_ids=source_ids or None) | |
| nb.artifacts.append(artifact) | |
| return state, render_doc_summary_section(state) | |
| # ββ Podcast ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def has_any_summary(state: UserData) -> bool: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return False | |
| return ( | |
| get_latest_artifact(nb, "document_summary") is not None | |
| or get_latest_artifact(nb, "conversation_summary") is not None | |
| ) | |
| def render_podcast_locked() -> str: | |
| return ( | |
| '<div class="locked-state">' | |
| '<div style="font-size:2.5rem; margin-bottom:16px;">π</div>' | |
| '<h3 style="color:#a0a0b8; font-weight:600; margin-bottom:8px;">Summary Required</h3>' | |
| '<p style="color:#707088; font-size:0.9rem; line-height:1.6;">' | |
| 'Generate a summary first in the <strong>Summary</strong> tab.<br>' | |
| 'The podcast is created from your summary to ensure accuracy.</p>' | |
| '<div style="margin-top:20px; display:inline-flex; align-items:center; gap:8px; ' | |
| 'padding:8px 18px; background:rgba(102,126,234,0.08); ' | |
| 'border:1px solid rgba(102,126,234,0.15); border-radius:20px; ' | |
| 'font-size:0.82rem; color:#8090d0;">' | |
| 'π Summary β ποΈ Podcast</div>' | |
| '</div>' | |
| ) | |
| def render_podcast_section(state: UserData) -> str: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return "" | |
| if not has_any_summary(state): | |
| return render_podcast_locked() | |
| latest_doc = get_latest_artifact(nb, "document_summary") | |
| latest_conv = get_latest_artifact(nb, "conversation_summary") | |
| latest = latest_doc or latest_conv | |
| sum_title = latest.title | |
| sum_time = _format_time(latest.created_at) | |
| html = ( | |
| f'<div style="display:flex; align-items:center; gap:12px; padding:12px 18px; ' | |
| f'background:rgba(34,197,94,0.06); border:1px solid rgba(34,197,94,0.15); ' | |
| f'border-radius:12px; margin-bottom:16px;">' | |
| f'<span style="font-size:1.2rem;">π</span>' | |
| f'<div><span style="font-size:0.85rem; color:#a0b8a0;">Based on: </span>' | |
| f'<strong style="color:#c0e0c0;">{sum_title}</strong>' | |
| f'<span style="color:#708070; font-size:0.8rem;"> ({sum_time})</span></div>' | |
| f'</div>' | |
| ) | |
| podcasts = get_all_artifacts(nb, "podcast") | |
| if podcasts: | |
| html += _render_artifact_content(podcasts[0]) | |
| html += _render_history(podcasts, "podcasts") | |
| else: | |
| html += ( | |
| '<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; ' | |
| 'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">' | |
| '<div style="font-size:2rem; margin-bottom:10px;">ποΈ</div>' | |
| '<p>No podcast generated yet.<br>Click <strong>Generate Podcast</strong> to create one.</p>' | |
| '</div>' | |
| ) | |
| return html | |
| def handle_gen_podcast(state: UserData) -> tuple[UserData, str]: | |
| """Generate a podcast using Claude script + HF TTS audio.""" | |
| from services.podcast_service import generate_podcast | |
| nb = get_active_notebook(state) | |
| if not nb or not has_any_summary(state): | |
| return state, render_podcast_section(state) | |
| latest_doc = get_latest_artifact(nb, "document_summary") | |
| latest_conv = get_latest_artifact(nb, "conversation_summary") | |
| latest_summary = latest_doc or latest_conv | |
| artifact = generate_podcast(nb, latest_summary.content) | |
| nb.artifacts.append(artifact) | |
| return state, render_podcast_section(state) | |
| # ββ Quiz βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def render_quiz_section(state: UserData) -> str: | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return "" | |
| quizzes = get_all_artifacts(nb, "quiz") | |
| if not quizzes: | |
| return ( | |
| '<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; ' | |
| 'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">' | |
| '<div style="font-size:2rem; margin-bottom:10px;">β</div>' | |
| '<p>No quiz generated yet.<br>Choose the number of questions and click <strong>Generate Quiz</strong>.</p>' | |
| '</div>' | |
| ) | |
| html = _render_artifact_content(quizzes[0]) | |
| html += _render_history(quizzes, "quizzes") | |
| return html | |
| def handle_gen_quiz(num_questions: int, state: UserData) -> tuple[UserData, str]: | |
| """Generate an interactive quiz using Claude.""" | |
| from services.quiz_service import generate_quiz | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return state, render_quiz_section(state) | |
| num_q = int(num_questions) if num_questions else 5 | |
| artifact = generate_quiz(nb, num_q) | |
| nb.artifacts.append(artifact) | |
| return state, render_quiz_section(state) | |
| # ββ Download Handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _find_artifact_by_id(state: UserData, artifact_id: str): | |
| """Find an artifact by ID in the active notebook.""" | |
| nb = get_active_notebook(state) | |
| if not nb: | |
| return None | |
| for artifact in nb.artifacts: | |
| if artifact.id == artifact_id: | |
| return artifact | |
| return None | |
| def download_artifact(artifact_id: str, state: UserData) -> str | None: | |
| """Download an artifact and return the file path for Gradio download.""" | |
| artifact = _find_artifact_by_id(state, artifact_id) | |
| if not artifact: | |
| return None | |
| return _save_artifact_to_temp_file(artifact) |