"""전체 리포트 생성 탭.""" 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 '미포함'}