"""리포트 탭 공통 유틸리티.
Feature별 미리보기, HTML 생성, CSV 변환 등 공통 함수.
"""
import io
import csv
import pandas as pd
import requests
import streamlit as st
import streamlit.components.v1 as components
from core.api_client import ChainShiftClient
def render_feature_section(
client: ChainShiftClient,
campaign_id: int,
feature_key: str,
title: str,
description: str,
start_date: str,
end_date: str,
api_key: str = "",
access_token: str = "",
):
"""단일 Feature 섹션 렌더링."""
html_state_key = f"html_content_{feature_key}_{campaign_id}"
insights_key = f"insights_enabled_{feature_key}_{campaign_id}"
with st.container(border=True):
# Header
c1, c2 = st.columns([4, 1])
with c1:
st.markdown(f"**{title}**")
st.caption(description)
# Preview Section (Lazy loaded)
with st.expander(f"👁️ 미리보기", expanded=False):
try:
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
if result.get("success"):
data = result.get("data", {})
render_feature_preview(feature_key, data)
else:
st.warning(f"데이터 로드 실패: {result.get('error', 'Unknown')}")
except Exception as e:
st.error(f"미리보기 오류: {e}")
# LLM Insights checkbox
enable_insights = st.checkbox(
"🤖 LLM 인사이트 포함",
value=st.session_state.get(insights_key, True),
key=f"insights_cb_{feature_key}",
help="컨설턴트 톤의 분석 코멘트를 추가합니다",
)
st.session_state[insights_key] = enable_insights
# Generated HTML display section
if html_state_key in st.session_state:
html_data = st.session_state[html_state_key]
html_content = html_data.get("content", "")
html_url = html_data.get("url", "")
st.success(f"✅ HTML 리포트 생성 완료" + (" (LLM 인사이트 포함)" if html_data.get("insights") else ""))
if html_content:
# Action buttons
col_open, col_dl, col_csv, col_reset = st.columns(4)
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=f"html_open_{feature_key}_disabled")
with col_dl:
st.download_button(
label="📥 HTML 다운로드",
data=b'\xef\xbb\xbf' + html_content.lstrip('\ufeff').encode("utf-8"),
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.html",
mime="text/html; charset=utf-8",
use_container_width=True,
key=f"html_dl_{feature_key}",
)
with col_csv:
try:
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
if result.get("success"):
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
st.download_button(
label="📊 CSV 다운로드",
data=csv_data.encode("utf-8-sig"),
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
mime="text/csv",
use_container_width=True,
key=f"csv_{feature_key}_post",
)
else:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_disabled")
except Exception:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_post_error")
with col_reset:
if st.button("🔄 다시 생성", key=f"html_reset_{feature_key}", use_container_width=True):
del st.session_state[html_state_key]
st.rerun()
# Inline preview
with st.expander("👁️ HTML 미리보기", expanded=False):
components.html(html_content, height=500, scrolling=True)
elif html_url:
# Fallback: HTML download failed, show direct link
col_link, col_csv, col_reset = st.columns(3)
with col_link:
st.link_button("🔗 리포트 열기 (외부 링크)", html_url, use_container_width=True)
with col_csv:
try:
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
if result.get("success"):
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
st.download_button(
label="📊 CSV 다운로드",
data=csv_data.encode("utf-8-sig"),
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
mime="text/csv",
use_container_width=True,
key=f"csv_{feature_key}_fallback",
)
else:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_disabled")
except Exception:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_fallback_error")
with col_reset:
if st.button("🔄 다시 생성", key=f"html_reset_{feature_key}", use_container_width=True):
del st.session_state[html_state_key]
st.rerun()
else:
# Generate button
col_html, col_csv = st.columns(2)
with col_html:
if st.button(f"📄 HTML 생성", key=f"html_{feature_key}", use_container_width=True):
spinner_text = "HTML 생성 중..." + (" (LLM 인사이트 포함)" if enable_insights else "")
with st.spinner(spinner_text):
try:
# Use url mode to avoid Vercel 4.5MB response limit.
# Download HTML from Supabase Storage directly.
result_url = client.generate_html_report(
campaign_id=campaign_id,
start_date=start_date,
end_date=end_date,
features=[feature_key],
enable_insights=enable_insights,
output_mode="url",
)
if result_url.get("success"):
data = result_url.get("data") or {}
html_url = data.get("html_url", "") if isinstance(data, dict) else ""
html_content = ""
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:
st.error("HTML 생성 실패: 스토리지 URL이 반환되지 않았습니다.")
else:
st.session_state[html_state_key] = {
"content": html_content,
"url": html_url,
"insights": enable_insights,
}
st.rerun()
else:
st.error("HTML 생성 실패: " + str(result_url.get("error", "Unknown")))
except Exception as e:
st.error(f"오류: {e}")
with col_csv:
try:
result = get_report_feature_data(api_key, campaign_id, feature_key, start_date, end_date, access_token=access_token)
if result.get("success"):
csv_data = convert_report_data_to_csv(feature_key, result.get("data", {}))
st.download_button(
label="📊 CSV 다운로드",
data=csv_data.encode("utf-8-sig"),
file_name=f"{feature_key}_{campaign_id}_{start_date}_{end_date}.csv",
mime="text/csv",
use_container_width=True,
key=f"csv_{feature_key}",
)
else:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_disabled")
except Exception:
st.button("📊 CSV 다운로드", disabled=True, use_container_width=True, key=f"csv_{feature_key}_error")
def render_feature_preview(feature_key: str, data: dict):
"""Feature별 미리보기 시각화."""
if feature_key == "overview":
cols = st.columns(4)
with cols[0]:
st.metric("총 질문 수", data.get("total_tasks", 0))
with cols[1]:
st.metric("총 답변 수", data.get("total_answers", 0))
with cols[2]:
st.metric("가시성", f"{data.get('overall_visibility_pct', 0):.1f}%")
with cols[3]:
dr = data.get("date_range", {})
period = f"{dr.get('start', '?')} ~ {dr.get('end', '?')}"
st.metric("분석 기간", period[:20])
elif feature_key == "visibility":
platforms = data.get("platforms", [])
if platforms:
rows = []
for p in platforms:
for b in p.get("brands", []):
rows.append({
"플랫폼": p.get("platform", ""),
"브랜드": b.get("brand_name", ""),
"가시성 (%)": b.get("visibility_pct", 0),
})
if rows:
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
else:
st.info("플랫폼 데이터 없음")
elif feature_key == "citations":
sources = data.get("sources", [])[:10]
if sources:
df = pd.DataFrame(sources)
cols = [c for c in ["source_host_url", "total_citations", "pct_of_total"] if c in df.columns]
if cols:
st.dataframe(df[cols], use_container_width=True, hide_index=True)
else:
st.info("인용 데이터 없음")
elif feature_key == "citation-trends":
sources = data.get("sources", [])
if sources:
rows = []
for s in sources:
for pt in s.get("trend", []):
rows.append({
"date": pt.get("task_date", ""),
"source": s.get("source_host_url", ""),
"citations": pt.get("citation_count", 0),
})
if rows:
df = pd.DataFrame(rows)
pivot = df.pivot_table(index="date", columns="source", values="citations", aggfunc="sum").fillna(0)
st.line_chart(pivot)
else:
st.info("시계열 데이터 없음")
elif feature_key == "content-types":
types = data.get("content_types", [])
if types:
df = pd.DataFrame(types)
if "content_type" in df.columns and "total_citations" in df.columns:
st.bar_chart(df.set_index("content_type")["total_citations"])
else:
st.info("콘텐츠 유형 데이터 없음")
elif feature_key == "sentiment":
in_house = data.get("in_house_brands", [])
competitor = data.get("competitor_brands", [])
if in_house:
st.markdown("**🏢 자사 브랜드**")
df_ih = pd.DataFrame(in_house)
cols_ih = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
cols_ih = [c for c in cols_ih if c in df_ih.columns]
if cols_ih:
st.dataframe(df_ih[cols_ih], use_container_width=True, hide_index=True)
if competitor:
st.markdown("**🎯 경쟁사 브랜드**")
df_comp = pd.DataFrame(competitor)
cols_comp = ["brand_name", "total_mentions", "positive_rate", "negative_rate"]
cols_comp = [c for c in cols_comp if c in df_comp.columns]
if cols_comp:
st.dataframe(df_comp[cols_comp], use_container_width=True, hide_index=True)
if not in_house and not competitor:
brands = data.get("brands", [])
if brands:
df = pd.DataFrame(brands)
cols = [c for c in ["brand_name", "brand_type", "positive_rate", "negative_rate"] if c in df.columns]
if cols:
st.dataframe(df[cols], use_container_width=True, hide_index=True)
else:
st.info("감정 분석 데이터 없음")
elif feature_key == "homepage-citations":
daily_data = data.get("daily_data", [])[:10]
if daily_data:
rows = []
for day in daily_data:
for entry in day.get("entries", []):
rows.append({
"날짜": day.get("task_date", ""),
"플랫폼": entry.get("platform", ""),
"인용 횟수": entry.get("citation_count", 0),
})
if rows:
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
else:
st.info("홈페이지 인용 데이터 없음")
@st.cache_data(ttl=300)
def get_report_feature_data(
api_key: str,
campaign_id: int,
feature: str,
start_date: str | None = None,
end_date: str | None = None,
access_token: str = "",
):
"""Fetch report feature data with caching."""
client = ChainShiftClient(api_key=api_key or None, access_token=access_token or None)
if feature == "overview":
return client.get_report_overview(campaign_id, start_date, end_date)
elif feature == "visibility":
return client.get_report_visibility(campaign_id, start_date, end_date)
elif feature == "citations":
return client.get_report_citations(campaign_id, start_date, end_date, limit=50)
elif feature == "citation-trends":
return client.get_report_citation_trends(campaign_id, start_date, end_date)
elif feature == "content-types":
return client.get_report_content_types(campaign_id, start_date, end_date)
elif feature == "sentiment":
return client.get_report_sentiment(campaign_id)
elif feature == "homepage-citations":
return client.get_report_homepage_citations(campaign_id, start_date, end_date)
else:
return {"success": False, "error": f"Unknown feature: {feature}"}
def convert_report_data_to_csv(feature: str, data: dict) -> str:
"""Convert report feature data to CSV format."""
output = io.StringIO()
writer = csv.writer(output)
if feature == "overview":
dr = data.get("date_range", {})
writer.writerow(["항목", "값"])
writer.writerow(["캠페인 ID", data.get("campaign_id", "")])
writer.writerow(["분석 기간", f"{dr.get('start', '')} ~ {dr.get('end', '')}"])
writer.writerow(["총 질문 수", data.get("total_tasks", 0)])
writer.writerow(["총 답변 수", data.get("total_answers", 0)])
writer.writerow(["가시성 비율 (%)", data.get("overall_visibility_pct", 0)])
elif feature == "visibility":
writer.writerow(["플랫폼", "브랜드", "유형", "가시성 (%)", "브랜드 언급 수", "총 답변 수"])
for platform in data.get("platforms", []):
for brand in platform.get("brands", []):
writer.writerow([
platform.get("platform", ""),
brand.get("brand_name", ""),
brand.get("brand_type", ""),
brand.get("visibility_pct", 0),
brand.get("brand_mentions", 0),
platform.get("total_answers", 0),
])
elif feature == "citations":
writer.writerow(["도메인", "유형", "인용 횟수", "답변 언급 수", "비율 (%)"])
for item in data.get("sources", []):
writer.writerow([
item.get("source_host_url", ""),
item.get("source_host_type", ""),
item.get("total_citations", 0),
item.get("total_answer_mentions", 0),
item.get("pct_of_total", 0),
])
elif feature == "citation-trends":
writer.writerow(["인용 출처", "유형", "날짜", "인용 횟수", "답변 언급 수", "비율 (%)"])
for source in data.get("sources", []):
host = source.get("source_host_url", "")
host_type = source.get("source_host_type", "")
for point in source.get("trend", []):
writer.writerow([
host,
host_type,
point.get("task_date", ""),
point.get("citation_count", 0),
point.get("answer_mention_count", 0),
point.get("citation_pct", 0),
])
elif feature == "content-types":
writer.writerow(["콘텐츠 유형", "인용 횟수", "답변 언급 수", "비율 (%)"])
for item in data.get("content_types", []):
writer.writerow([
item.get("content_type", ""),
item.get("total_citations", 0),
item.get("total_answer_mentions", 0),
item.get("pct_of_total", 0),
])
elif feature == "sentiment":
writer.writerow(["브랜드", "유형", "총 멘션", "긍정 %", "부정 %", "중립 %"])
for item in data.get("in_house_brands", []):
t = item.get("total_mentions", 0)
neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
writer.writerow([
item.get("brand_name", ""),
"자사",
t,
f"{item.get('positive_rate', 0):.1f}",
f"{item.get('negative_rate', 0):.1f}",
f"{neutral:.1f}",
])
for item in data.get("competitor_brands", []):
t = item.get("total_mentions", 0)
neutral = round(item.get("neutral_count", 0) / t * 100, 1) if t > 0 else 0.0
writer.writerow([
item.get("brand_name", ""),
"경쟁사",
item.get("total_mentions", 0),
f"{item.get('positive_rate', 0):.1f}",
f"{item.get('negative_rate', 0):.1f}",
f"{neutral:.1f}",
])
if not data.get("in_house_brands") and not data.get("competitor_brands"):
for item in data.get("brands", []):
pos = item.get("positive_rate", item.get("positive", 0))
neg = item.get("negative_rate", item.get("negative", 0))
neutral = 100 - pos - neg
writer.writerow([
item.get("brand_name", item.get("name", "")),
item.get("brand_type", item.get("type", "")),
item.get("total_mentions", 0),
f"{pos:.1f}",
f"{neg:.1f}",
f"{neutral:.1f}",
])
elif feature == "homepage-citations":
writer.writerow(["날짜", "플랫폼", "인용 출처", "인용 횟수", "답변 언급 수"])
for day in data.get("daily_data", []):
task_date = day.get("task_date", "")
for entry in day.get("entries", []):
writer.writerow([
task_date,
entry.get("platform", ""),
entry.get("source_host_url", ""),
entry.get("citation_count", 0),
entry.get("answer_mention_count", 0),
])
return output.getvalue()