"""전체 리포트 생성 탭.""" from concurrent.futures import ThreadPoolExecutor, as_completed import pandas as pd import requests import streamlit as st import streamlit.components.v1 as components from core.api_client import ChainShiftClient def _build_one( api_key: str, campaign_id: int, feat_key: str, start_date: str, end_date: str, enable_insights: bool, homepage_urls: list[str] | None, ) -> tuple[str, dict | None, str | None]: """Worker thread — st.* 호출 금지. 독립 HTTP 클라이언트로 feature 빌드.""" try: thread_client = ChainShiftClient(api_key=api_key) result = thread_client.build_html_feature( campaign_id=campaign_id, feature=feat_key, start_date=start_date, end_date=end_date, enable_insights=enable_insights, enable_action_items=True, homepage_urls=homepage_urls if feat_key == "homepage-citations" else None, ) return feat_key, result, None except Exception as e: return feat_key, None, str(e) @st.cache_data(ttl=60) def _fetch_report_history( campaign_id: int, page: int = 1, page_size: int = 20, _api_key: str = "", _access_token: str = "", ) -> dict: """Cached fetch for HTML report history.""" client = ChainShiftClient(api_key=_api_key or None, access_token=_access_token or None) return client.get_html_report_history(campaign_id, page=page, page_size=page_size) AVAILABLE_FEATURES = [ ("overview", "1. 가시성 분석 개요"), ("visibility", "2. AI 검색 가시성"), ("citations", "3. 인용 출처 분석"), ("citation-trends", "4. 인용 출처 시계열"), ("content-types", "5. 콘텐츠 유형"), ("sentiment", "6. 브랜드 감정"), ("homepage-citations", "7. 홈페이지 인용률"), ] def render(client: ChainShiftClient, base_ctx: dict, start_date: str, end_date: str): """전체 리포트 생성 섹션.""" st.markdown("#### 📄 전체 HTML 리포트 생성") st.caption("7개 Feature를 포함한 통합 HTML 리포트를 생성합니다. LLM 인사이트로 컨설턴트 톤의 분석을 추가할 수 있습니다.") with st.expander("⚙️ 리포트 옵션", expanded=True): st.markdown("**포함할 Feature 선택**") selected_features = [] col1, col2 = st.columns(2) for i, (feat_key, feat_label) in enumerate(AVAILABLE_FEATURES): with col1 if i < 4 else col2: if st.checkbox(feat_label, value=True, key=f"reports:full_feat_{feat_key}"): selected_features.append(feat_key) st.markdown("---") enable_insights = st.checkbox( "🤖 LLM 인사이트 생성", value=True, help="Gemini API를 사용하여 컨설턴트 톤의 분석 인사이트를 추가합니다 (생성 시간 증가)", ) st.text_area( "Homepage URLs (줄바꿈 구분, 선택)", help="홈페이지 인용률 분석에 사용할 URL 목록", key="reports:homepage_urls_input", height=80, ) # Parse homepage URLs from text area homepage_urls_raw = st.session_state.get("reports:homepage_urls_input", "") homepage_urls = [u.strip() for u in homepage_urls_raw.splitlines() if u.strip()] or None # Feature display name lookup _feat_display = dict(AVAILABLE_FEATURES) if st.button("🚀 전체 리포트 생성", key="reports:generate_html_btn", type="primary", disabled=not selected_features): campaign_id = base_ctx["campaign_id"] total = len(selected_features) progress_bar = st.progress(0, text="리포트 생성 준비 중...") status_container = st.container() built_features: list[dict] = [] skipped_features: list[str] = [] total_build_ms = 0 # Phase 1: Build features in parallel (I/O-bound HTTP calls) with ThreadPoolExecutor(max_workers=total) as executor: futures = { executor.submit( _build_one, client.api_key, campaign_id, feat_key, start_date, end_date, enable_insights, homepage_urls, ): feat_key for feat_key in selected_features } completed = 0 for future in as_completed(futures): feat_key = futures[future] feat_label = _feat_display.get(feat_key, feat_key) completed += 1 fk, result, error = future.result() if error: skipped_features.append(feat_key) with status_container: st.caption(f" {feat_label} 실패: {error}") elif result and result.get("success"): feat_resp = result["data"] built_features.append(feat_resp["feature_data"]) build_ms = feat_resp.get("build_time_ms", 0) total_build_ms += build_ms insight_tag = " +인사이트" if feat_resp.get("insights_generated") else "" with status_container: st.caption(f" {feat_label} ({build_ms/1000:.1f}s{insight_tag})") else: skipped_features.append(feat_key) with status_container: st.caption(f" {feat_label} 건너뜀") progress_bar.progress( completed / (total + 1), text=f"({completed}/{total}) 빌드 완료...", ) # Restore original feature order for rendering feat_order = {k: i for i, k in enumerate(selected_features)} built_features.sort(key=lambda f: feat_order.get(f.get("feature_id", ""), 99)) if not built_features: progress_bar.empty() st.error("모든 Feature 생성에 실패했습니다.") else: # Phase 2: Render final report progress_bar.progress( total / (total + 1), text="HTML 리포트 조립 중...", ) try: render_result = client.render_html_report( campaign_id=campaign_id, features_data=built_features, start_date=start_date, end_date=end_date, enable_insights=enable_insights, enable_action_items=True, output_mode="url", ) if render_result.get("success"): data = render_result.get("data") or {} if not isinstance(data, dict): progress_bar.empty() st.error("리포트 렌더링 실패: 서버 응답이 비정상입니다.") else: render_ms = data.get("generation_time_ms", 0) # Download HTML from Supabase Storage URL directly. # url mode avoids Vercel 4.5MB response body limit. html_content = "" html_url = data.get("html_url") or "" if html_url: try: dl_resp = requests.get(html_url, timeout=30) dl_resp.raise_for_status() dl_resp.encoding = "utf-8" html_content = dl_resp.text except Exception as dl_err: st.warning(f"HTML 다운로드 실패, URL 링크로 대체: {dl_err}") if not html_url and not html_content: progress_bar.empty() st.error("리포트 렌더링 실패: 스토리지 URL이 반환되지 않았습니다.") else: # Persist results in session_state for rerun survival st.session_state["full_report_result"] = { "html_content": html_content, "html_url": html_url, "report_id": data.get("report_id", "")[:8], "features_generated": data.get("features_generated", []), "file_size_kb": round(data.get("file_size_bytes", 0) / 1024, 1), "total_sec": round((total_build_ms + render_ms) / 1000, 1), "insights_generated": data.get("insights_generated", False), "skipped_features": skipped_features, "campaign_id": campaign_id, "start_date": start_date, "end_date": end_date, } progress_bar.empty() st.rerun() else: progress_bar.empty() st.error("리포트 렌더링 실패: " + str(render_result.get("error", "Unknown error"))) except Exception as e: progress_bar.empty() st.error(f"리포트 렌더링 오류: {e}") elif not selected_features: st.warning("최소 1개 이상의 Feature를 선택하세요.") # ── Results display (persists across reruns via session_state) ── report_state = st.session_state.get("full_report_result") if report_state: html_content = report_state["html_content"] report_id = report_state["report_id"] features_generated = report_state["features_generated"] file_size_kb = report_state["file_size_kb"] total_sec = report_state["total_sec"] skipped = report_state["skipped_features"] r_campaign_id = report_state["campaign_id"] r_start = report_state["start_date"] r_end = report_state["end_date"] if skipped: st.warning(f"일부 Feature를 건너뛰고 리포트를 생성했습니다: {', '.join(skipped)}") st.success("리포트가 생성되었습니다!") st.markdown(f"""

리포트 생성 완료

Report ID: {report_id}...

Features: {len(features_generated)}개 ({len(skipped)}개 건너뜀)

파일 크기: {file_size_kb} KB

생성 시간: {total_sec}초

LLM 인사이트: {'포함' if report_state.get('insights_generated') else '미포함'}

""", unsafe_allow_html=True) html_url = report_state.get("html_url", "") if html_content: col_open, col_download, col_clear = st.columns(3) with col_open: if html_url: st.link_button("새 창에서 보기", html_url, use_container_width=True) else: st.button("새 창에서 보기", disabled=True, use_container_width=True, key="reports:open_disabled") with col_download: file_name = f"AI_가시성_리포트_{r_campaign_id}_{r_start}_{r_end}.html" st.download_button( label="HTML 다운로드", data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"), file_name=file_name, mime="text/html; charset=utf-8", use_container_width=True, key="reports:full_report_download", ) with col_clear: if st.button("초기화", key="reports:clear_result", use_container_width=True): del st.session_state["full_report_result"] st.rerun() with st.expander("리포트 미리보기", expanded=False): components.html(html_content, height=800, scrolling=True) elif html_url: # Fallback: HTML download failed, show direct link col_link, col_clear = st.columns(2) with col_link: st.link_button("리포트 열기 (외부 링크)", html_url, use_container_width=True) with col_clear: if st.button("초기화", key="reports:clear_result", use_container_width=True): del st.session_state["full_report_result"] st.rerun() st.markdown("---") # Report History st.markdown("##### 📋 생성 이력") try: history_result = _fetch_report_history( base_ctx["campaign_id"], _api_key=base_ctx.get("api_key", ""), _access_token=base_ctx.get("access_token", ""), ) if history_result.get("success"): history_data = history_result["data"] reports = history_data.get("items", []) total = history_data.get("total", 0) if reports: st.markdown(f"총 **{total}**건의 리포트가 생성되었습니다.") history_rows = [] for r in reports: status_emoji = "✅" if r.get("status") == "completed" else "❌" features = r.get("features_included", []) file_size_kb = round(r.get("file_size_bytes", 0) / 1024, 1) history_rows.append({ "생성일": r.get("created_at", "")[:16].replace("T", " "), "상태": f"{status_emoji}", "Features": f"{len(features)}/{len(AVAILABLE_FEATURES)}", "기간": f"{r.get('start_date', '?')} ~ {r.get('end_date', '?')}", "크기": f"{file_size_kb} KB", "링크": r.get("html_url") or "-", }) df = pd.DataFrame(history_rows) st.dataframe( df, use_container_width=True, hide_index=True, column_config={ "링크": st.column_config.LinkColumn("링크", display_text="열기"), }, ) else: st.info("아직 생성된 리포트가 없습니다.") except Exception as e: st.warning(f"이력 로드 실패: {e}")