GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
14.9 kB
"""์ „์ฒด ๋ฆฌํฌํŠธ ์ƒ์„ฑ ํƒญ."""
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"""
<div style="background: linear-gradient(135deg, #E5E2FF 0%, #F0FDFA 100%);
padding: 20px; border-radius: 12px; margin: 16px 0;">
<h4 style="margin: 0 0 12px 0; color: #2B239B;">๋ฆฌํฌํŠธ ์ƒ์„ฑ ์™„๋ฃŒ</h4>
<p style="margin: 8px 0;"><strong>Report ID:</strong> {report_id}...</p>
<p style="margin: 8px 0;"><strong>Features:</strong> {len(features_generated)}๊ฐœ ({len(skipped)}๊ฐœ ๊ฑด๋„ˆ๋œ€)</p>
<p style="margin: 8px 0;"><strong>ํŒŒ์ผ ํฌ๊ธฐ:</strong> {file_size_kb} KB</p>
<p style="margin: 8px 0;"><strong>์ƒ์„ฑ ์‹œ๊ฐ„:</strong> {total_sec}์ดˆ</p>
<p style="margin: 8px 0;"><strong>LLM ์ธ์‚ฌ์ดํŠธ:</strong> {'ํฌํ•จ' if report_state.get('insights_generated') else '๋ฏธํฌํ•จ'}</p>
</div>
""", 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}")