Spaces:
Sleeping
Sleeping
| """ํค์๋ ๋๋ฆด๋ค์ด โ ์์ฌ/๊ฒฝ์์ฌ ํค์๋ ์์ธ ๋ชฉ๋ก + ํ์ด์ง๋ค์ด์ .""" | |
| import html | |
| import streamlit as st | |
| from core.api_client import ChainShiftClient | |
| from .detail import render_keyword_result_card | |
| from .export_kw import render_export | |
| def render_keyword_drilldown(data: dict, keywords_list: list[dict]): | |
| """ํค์๋ ์ ํ -> ๋ฌธ์ฅ ๋ชฉ๋ก + ๊ฐ์ฑ + ๋ธ๋๋.""" | |
| st.markdown("**ํค์๋ ์์ธ ๋๋ฆด๋ค์ด**") | |
| keyword_names = ["์ ์ฒด"] + [kw.get("keyword", "") for kw in keywords_list] | |
| selected_keyword_raw = st.selectbox( | |
| "ํค์๋ ์ ํ", | |
| options=keyword_names, | |
| key="sentiment:kw_drilldown_select", | |
| ) | |
| selected_keyword = None if selected_keyword_raw == "์ ์ฒด" else selected_keyword_raw | |
| # Filters โ Row 1: ๊ฐ์ฑ, ๋ธ๋๋, ํ๋ซํผ, 2์ฐจ ๊ฒ์ฆ | |
| f1, f2, f3, f4 = st.columns(4) | |
| with f1: | |
| sentiment_filter = st.selectbox( | |
| "๊ฐ์ฑ", | |
| options=["์ ์ฒด", "positive", "neutral", "negative"], | |
| format_func=lambda x: {"์ ์ฒด": "์ ์ฒด", "positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(x, x), | |
| key="sentiment:kw_drilldown_sentiment", | |
| ) | |
| with f2: | |
| brand_filter = st.selectbox( | |
| "๋ธ๋๋ ๊ตฌ๋ถ", | |
| options=["์ ์ฒด", "๋ธ๋๋ ํฌํจ", "๋น๋ธ๋๋"], | |
| key="sentiment:kw_drilldown_brand", | |
| ) | |
| with f3: | |
| platform_filter = st.selectbox( | |
| "ํ๋ซํผ", | |
| options=["์ ์ฒด", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"], | |
| key="sentiment:kw_drilldown_platform", | |
| ) | |
| with f4: | |
| llm_status_filter = st.selectbox( | |
| "2์ฐจ ๊ฒ์ฆ", | |
| options=["์ ์ฒด", "์ ํ", "์คํ", "๋ฏธ๊ฒ์ฆ"], | |
| key="sentiment:kw_drilldown_llm", | |
| ) | |
| # Build server-side filter params | |
| sentiment_param = sentiment_filter if sentiment_filter != "์ ์ฒด" else None | |
| platform_param = platform_filter if platform_filter != "์ ์ฒด" else None | |
| brand_only_param = brand_filter == "๋ธ๋๋ ํฌํจ" | |
| no_brand_param = brand_filter == "๋น๋ธ๋๋" | |
| # Map LLM filter -> server-side params (llm_is_negative column) | |
| llm_verified_param = None | |
| llm_is_negative_param = None # True (์ ํ) | False (์คํ) | None | |
| if llm_status_filter == "์ ํ": | |
| llm_verified_param = "verified" | |
| llm_is_negative_param = True | |
| elif llm_status_filter == "์คํ": | |
| llm_verified_param = "verified" | |
| llm_is_negative_param = False | |
| elif llm_status_filter == "๋ฏธ๊ฒ์ฆ": | |
| llm_verified_param = "unverified" | |
| # Filters โ Row 2: ํ์ด์ง ํฌ๊ธฐ + Excel ๋ค์ด๋ก๋ | |
| dl_col, _, size_col = st.columns([2, 2, 1]) | |
| with dl_col: | |
| render_export( | |
| data, | |
| keyword=selected_keyword, | |
| sentiment=sentiment_param, | |
| brand_only=brand_only_param, | |
| no_brand=no_brand_param, | |
| platform=platform_param, | |
| llm_verified=llm_verified_param, | |
| llm_is_negative=llm_is_negative_param, | |
| ) | |
| with size_col: | |
| page_size = st.selectbox("ํ์ด์ง ํฌ๊ธฐ", options=[20, 50, 100], index=1, key="sentiment:kw_page_size") | |
| # Pagination state | |
| if "sentiment:kw_drill_page" not in st.session_state: | |
| st.session_state["sentiment:kw_drill_page"] = 1 | |
| # Reset page on filter change | |
| kw_filter_key = f"{selected_keyword}_{sentiment_filter}_{brand_filter}_{platform_filter}_{llm_status_filter}_{page_size}" | |
| if st.session_state.get("sentiment:kw_drill_last_filters") != kw_filter_key: | |
| st.session_state["sentiment:kw_drill_page"] = 1 | |
| st.session_state["sentiment:kw_drill_last_filters"] = kw_filter_key | |
| current_page = st.session_state["sentiment:kw_drill_page"] | |
| # Fetch results โ direct Supabase (bypass Vercel 10s timeout) | |
| try: | |
| items, total = fetch_keyword_drilldown( | |
| campaign_id=data["campaign_id"], | |
| keyword=selected_keyword, | |
| sentiment=sentiment_param, | |
| llm_verified=llm_verified_param, | |
| llm_is_negative=llm_is_negative_param, | |
| platform=platform_param, | |
| brand_only=brand_only_param, | |
| no_brand=no_brand_param, | |
| page=current_page, | |
| page_size=page_size, | |
| ) | |
| except Exception as e: | |
| st.error(f"ํค์๋ ๊ฒฐ๊ณผ ๋ก๋ ์คํจ: {e}") | |
| return | |
| total_pages = max(1, (total + page_size - 1) // page_size) | |
| has_more = len(items) == page_size # count="planned" may underestimate | |
| start_idx = (current_page - 1) * page_size + 1 | |
| end_idx = min(current_page * page_size, total) | |
| if total_pages > 1 or has_more: | |
| col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1]) | |
| with col_info: | |
| st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด** | ํ์ด์ง {current_page}/{total_pages} ({start_idx}-{end_idx}๊ฑด)") | |
| with col_prev: | |
| if st.button("โฌ ๏ธ ์ด์ ", disabled=current_page <= 1, key="sentiment:kw_drill_prev"): | |
| st.session_state["sentiment:kw_drill_page"] = current_page - 1 | |
| st.rerun() | |
| with col_page: | |
| new_page = st.number_input( | |
| "ํ์ด์ง", min_value=1, max_value=max(total_pages, current_page + 1), | |
| value=current_page, label_visibility="collapsed", key="sentiment:kw_drill_page_input", | |
| ) | |
| if new_page != current_page: | |
| st.session_state["sentiment:kw_drill_page"] = new_page | |
| st.rerun() | |
| with col_next: | |
| if st.button("๋ค์ โก๏ธ", disabled=not has_more, key="sentiment:kw_drill_next"): | |
| st.session_state["sentiment:kw_drill_page"] = current_page + 1 | |
| st.rerun() | |
| else: | |
| st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด**") | |
| if not items: | |
| st.info("์กฐ๊ฑด์ ๋ง๋ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค") | |
| return | |
| for i, item in enumerate(items): | |
| unique_idx = (current_page - 1) * page_size + i | |
| render_keyword_result_card(data, item, unique_idx) | |
| def render_competitor_summary(data: dict): | |
| """๊ฒฝ์์ฌ ๋ธ๋๋ ์์ฝ ์นด๋.""" | |
| try: | |
| client = ChainShiftClient(api_key=data.get("api_key"), access_token=data.get("access_token")) | |
| response = client.get_keyword_brand_analysis(data["campaign_id"], competitor=True) | |
| items = (response.get("data") or {}).get("items", []) | |
| except Exception: | |
| return | |
| if not items: | |
| return | |
| # Aggregate by competitor brand | |
| brand_stats: dict[str, dict] = {} | |
| for item in items: | |
| brand = item.get("brand", "") | |
| if not brand: | |
| continue | |
| if brand not in brand_stats: | |
| brand_stats[brand] = {"total": 0, "positive": 0, "neutral": 0, "negative": 0} | |
| sent = item.get("sentiment", {}) | |
| brand_stats[brand]["total"] += item.get("total", 0) | |
| brand_stats[brand]["positive"] += sent.get("positive", 0) | |
| brand_stats[brand]["neutral"] += sent.get("neutral", 0) | |
| brand_stats[brand]["negative"] += sent.get("negative", 0) | |
| if not brand_stats: | |
| return | |
| total_mentions = sum(b["total"] for b in brand_stats.values()) | |
| brand_chips = [] | |
| for brand, stats in sorted(brand_stats.items(), key=lambda x: -x[1]["total"]): | |
| neg_rate = (stats["negative"] / stats["total"] * 100) if stats["total"] > 0 else 0 | |
| pos_rate = (stats["positive"] / stats["total"] * 100) if stats["total"] > 0 else 0 | |
| brand_chips.append( | |
| f'<span style="background:#FEF3C7;padding:3px 8px;border-radius:6px;font-size:12px;margin:2px;">' | |
| f'๐ข {html.escape(brand)} {stats["total"]:,}๊ฑด ' | |
| f'<span style="color:#10B981;">๊ธ์ {pos_rate:.0f}%</span> ' | |
| f'<span style="color:#EF4444;">๋ถ์ {neg_rate:.0f}%</span></span>' | |
| ) | |
| st.markdown(f""" | |
| <div style="background: linear-gradient(135deg, #FFFBEB 0%, #FEF3C7 100%); border: 1px solid #FCD34D; | |
| border-radius: 12px; padding: 16px; margin-bottom: 16px;"> | |
| <div style="font-size: 13px; color: #92400E; font-weight: 600; margin-bottom: 8px;"> | |
| ๊ฒฝ์์ฌ ๋ธ๋๋ ์์ฝ โ ์ด {total_mentions:,}๊ฑด ์ธ๊ธ, {len(brand_stats)}๊ฐ ๋ธ๋๋ | |
| </div> | |
| <div style="display:flex;gap:6px;flex-wrap:wrap;"> | |
| {' '.join(brand_chips)} | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def render_competitor_drilldown(data: dict): | |
| """๊ฒฝ์์ฌ ํค์๋ ๋๋ฆด๋ค์ด.""" | |
| st.markdown("**๊ฒฝ์์ฌ ํค์๋ ์์ธ ๋๋ฆด๋ค์ด**") | |
| keyword_data = data.get("keyword_data", {}) | |
| keywords_list = keyword_data.get("keywords", []) | |
| keyword_names = ["์ ์ฒด"] + [kw.get("keyword", "") for kw in keywords_list] | |
| if len(keyword_names) <= 1: | |
| st.info("ํค์๋ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค") | |
| return | |
| selected_raw = st.selectbox( | |
| "ํค์๋ ์ ํ", | |
| options=keyword_names, | |
| key="sentiment:comp_kw_drilldown_select", | |
| ) | |
| selected_keyword = None if selected_raw == "์ ์ฒด" else selected_raw | |
| # Filters โ matching keyword drilldown pattern | |
| f1, f2, f3 = st.columns(3) | |
| with f1: | |
| sentiment_filter = st.selectbox( | |
| "๊ฐ์ฑ", | |
| options=["์ ์ฒด", "positive", "neutral", "negative"], | |
| format_func=lambda x: {"์ ์ฒด": "์ ์ฒด", "positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(x, x), | |
| key="sentiment:comp_drill_sentiment", | |
| ) | |
| with f2: | |
| platform_filter = st.selectbox( | |
| "ํ๋ซํผ", | |
| options=["์ ์ฒด", "CHATGPT", "GEMINI", "PERPLEXITY", "CLAUDE"], | |
| key="sentiment:comp_drill_platform", | |
| ) | |
| with f3: | |
| llm_status_filter = st.selectbox( | |
| "2์ฐจ ๊ฒ์ฆ", | |
| options=["์ ์ฒด", "์ ํ", "์คํ", "๋ฏธ๊ฒ์ฆ"], | |
| key="sentiment:comp_drill_llm", | |
| ) | |
| # Build server-side filter params | |
| sentiment_param = sentiment_filter if sentiment_filter != "์ ์ฒด" else None | |
| platform_param = platform_filter if platform_filter != "์ ์ฒด" else None | |
| llm_is_negative_param = None | |
| llm_verified_param = None | |
| if llm_status_filter == "์ ํ": | |
| llm_is_negative_param = True | |
| elif llm_status_filter == "์คํ": | |
| llm_is_negative_param = False | |
| elif llm_status_filter == "๋ฏธ๊ฒ์ฆ": | |
| llm_verified_param = "unverified" | |
| # Export + page size row | |
| dl_col, _, size_col = st.columns([2, 2, 1]) | |
| with dl_col: | |
| _render_competitor_export( | |
| data, | |
| keyword=selected_keyword, | |
| sentiment=sentiment_param, | |
| platform=platform_param, | |
| llm_is_negative=llm_is_negative_param, | |
| llm_verified=llm_verified_param, | |
| ) | |
| with size_col: | |
| page_size = st.selectbox("ํ์ด์ง ํฌ๊ธฐ", options=[20, 50, 100], index=1, key="sentiment:comp_drill_page_size") | |
| # Pagination state | |
| if "sentiment:comp_drill_page" not in st.session_state: | |
| st.session_state["sentiment:comp_drill_page"] = 1 | |
| # Reset page on filter change | |
| comp_filter_key = f"{selected_keyword}_{sentiment_filter}_{platform_filter}_{llm_status_filter}_{page_size}" | |
| if st.session_state.get("sentiment:comp_drill_last_filters") != comp_filter_key: | |
| st.session_state["sentiment:comp_drill_page"] = 1 | |
| st.session_state["sentiment:comp_drill_last_filters"] = comp_filter_key | |
| current_page = st.session_state["sentiment:comp_drill_page"] | |
| try: | |
| items, total = fetch_competitor_drilldown( | |
| campaign_id=data["campaign_id"], | |
| keyword=selected_keyword, | |
| sentiment=sentiment_param, | |
| llm_verified=llm_verified_param, | |
| llm_is_negative=llm_is_negative_param, | |
| platform=platform_param, | |
| page=current_page, | |
| page_size=page_size, | |
| ) | |
| except Exception as e: | |
| st.error(f"๊ฒฝ์์ฌ ํค์๋ ๊ฒฐ๊ณผ ๋ก๋ ์คํจ: {e}") | |
| return | |
| total_pages = max(1, (total + page_size - 1) // page_size) | |
| has_more = len(items) == page_size # count="planned" may underestimate | |
| start_idx = (current_page - 1) * page_size + 1 | |
| end_idx = min(current_page * page_size, total) | |
| if total_pages > 1 or has_more: | |
| col_info, col_prev, col_page, col_next = st.columns([3, 1, 1, 1]) | |
| with col_info: | |
| st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด** | ํ์ด์ง {current_page}/{total_pages} ({start_idx}-{end_idx}๊ฑด)") | |
| with col_prev: | |
| if st.button("โฌ ๏ธ ์ด์ ", disabled=current_page <= 1, key="sentiment:comp_drill_prev"): | |
| st.session_state["sentiment:comp_drill_page"] = current_page - 1 | |
| st.rerun() | |
| with col_page: | |
| new_page = st.number_input( | |
| "ํ์ด์ง", min_value=1, max_value=max(total_pages, current_page + 1), | |
| value=current_page, label_visibility="collapsed", key="sentiment:comp_drill_page_input", | |
| ) | |
| if new_page != current_page: | |
| st.session_state["sentiment:comp_drill_page"] = new_page | |
| st.rerun() | |
| with col_next: | |
| if st.button("๋ค์ โก๏ธ", disabled=not has_more, key="sentiment:comp_drill_next"): | |
| st.session_state["sentiment:comp_drill_page"] = current_page + 1 | |
| st.rerun() | |
| else: | |
| st.markdown(f"**์ ์ฒด ~{total:,}๊ฑด**") | |
| if not items: | |
| st.info("์กฐ๊ฑด์ ๋ง๋ ๊ฒฝ์์ฌ ํค์๋ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.") | |
| return | |
| for i, item in enumerate(items): | |
| unique_idx = (current_page - 1) * page_size + i | |
| render_keyword_result_card(data, item, unique_idx, is_competitor=True) | |
| def fetch_competitor_drilldown( | |
| campaign_id: int, | |
| keyword: str | None = None, | |
| sentiment: str | None = None, | |
| llm_verified: str | None = None, | |
| llm_is_negative: bool | None = None, | |
| platform: str | None = None, | |
| page: int = 1, | |
| page_size: int = 50, | |
| ) -> tuple[list[dict], int]: | |
| """๊ฒฝ์์ฌ ํค์๋ ์ง์ Supabase ์ฟผ๋ฆฌ (Vercel ํ์์์ ์ฐํ). | |
| Filters match fetch_keyword_drilldown for consistency. | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| sb = get_supabase_client() | |
| query = ( | |
| sb.table("keyword_sentiment_results") | |
| .select("*", count="planned") | |
| .eq("campaign_id", campaign_id) | |
| .is_("brand_name", "null") | |
| ) | |
| # competitor_llm_verified filter (was hardcoded True, now conditional) | |
| if llm_verified == "unverified": | |
| query = query.or_("competitor_llm_verified.is.null,competitor_llm_verified.eq.false") | |
| else: | |
| # ์ ์ฒด/์ ํ/์คํ: only show LLM-analyzed results | |
| query = query.eq("competitor_llm_verified", True) | |
| if keyword: | |
| query = query.eq("keyword", keyword) | |
| if sentiment: | |
| query = query.eq("keyword_sentiment", sentiment) | |
| if platform: | |
| query = query.eq("platform", platform) | |
| if llm_is_negative is not None: | |
| query = query.eq("competitor_llm_is_negative", llm_is_negative) | |
| offset = (page - 1) * page_size | |
| query = query.order("created_at", desc=True).range(offset, offset + page_size - 1) | |
| result = query.execute() | |
| return result.data or [], result.count or 0 | |
| def fetch_keyword_drilldown( | |
| campaign_id: int, | |
| keyword: str | None = None, | |
| sentiment: str | None = None, | |
| llm_verified: str | None = None, | |
| llm_is_negative: bool | None = None, | |
| platform: str | None = None, | |
| brand_only: bool = False, | |
| no_brand: bool = False, | |
| page: int = 1, | |
| page_size: int = 50, | |
| ) -> tuple[list[dict], int]: | |
| """์ง์ Supabase ํ์ด์ง๋ค์ด์ ์ฟผ๋ฆฌ (Vercel 10s ํ์์์ ์ฐํ). | |
| Args: | |
| llm_is_negative: True=์ ํ(๋ถ์ ํ์ ), False=์คํ(๋ถ์ ์๋), None=์ ์ฒด | |
| Returns: | |
| (items, total_count) | |
| """ | |
| from core.supabase_client import get_supabase_client | |
| sb = get_supabase_client() | |
| query = sb.table("keyword_sentiment_results").select("*", count="planned") | |
| query = query.eq("campaign_id", campaign_id) | |
| if keyword: | |
| query = query.eq("keyword", keyword) | |
| if sentiment: | |
| query = query.eq("keyword_sentiment", sentiment) | |
| if platform: | |
| query = query.eq("platform", platform) | |
| if brand_only: | |
| query = query.not_.is_("brand_name", "null") | |
| elif no_brand: | |
| query = query.is_("brand_name", "null") | |
| if llm_is_negative is not None: | |
| query = query.eq("llm_is_negative", llm_is_negative) | |
| elif llm_verified == "verified": | |
| query = query.eq("llm_verified", True) | |
| elif llm_verified == "unverified": | |
| query = query.or_("llm_verified.is.null,llm_verified.eq.false") | |
| offset = (page - 1) * page_size | |
| query = query.order("created_at", desc=True).range(offset, offset + page_size - 1) | |
| result = query.execute() | |
| return result.data or [], result.count or 0 | |
| def _render_competitor_export( | |
| data: dict, | |
| keyword: str | None = None, | |
| sentiment: str | None = None, | |
| platform: str | None = None, | |
| llm_is_negative: bool | None = None, | |
| llm_verified: str | None = None, | |
| ): | |
| """๊ฒฝ์์ฌ ๋๋ฆด๋ค์ด CSV export (ํค์๋ ํญ export_kw.py ํจํด ์ผ์น).""" | |
| filter_parts = [] | |
| if keyword: | |
| filter_parts.append(f"ํค์๋: {keyword}") | |
| if sentiment: | |
| label = {"positive": "๊ธ์ ", "neutral": "์ค๋ฆฝ", "negative": "๋ถ์ "}.get(sentiment, sentiment) | |
| filter_parts.append(f"๊ฐ์ฑ: {label}") | |
| if platform: | |
| filter_parts.append(f"ํ๋ซํผ: {platform}") | |
| if llm_verified == "unverified": | |
| filter_parts.append("๋ฏธ๊ฒ์ฆ๋ง") | |
| elif llm_is_negative is True: | |
| filter_parts.append("์ ํ๋ง") | |
| elif llm_is_negative is False: | |
| filter_parts.append("์คํ๋ง") | |
| if filter_parts: | |
| st.caption(f"๐ฅ ํํฐ: {' | '.join(filter_parts)}") | |
| if st.button("๐ฅ Excel ๋ค์ด๋ก๋", key="sentiment:comp_drill_export_btn"): | |
| with st.spinner("Excel ์์ฑ ์ค..."): | |
| try: | |
| xlsx = _export_competitor_drilldown( | |
| data["campaign_id"], | |
| keyword=keyword, | |
| sentiment=sentiment, | |
| platform=platform, | |
| llm_is_negative=llm_is_negative, | |
| llm_verified=llm_verified, | |
| ) | |
| st.session_state["sentiment:comp_drill_excel"] = xlsx | |
| st.session_state["sentiment:comp_drill_excel_ready"] = True | |
| except Exception as e: | |
| st.error(f"๋ค์ด๋ก๋ ์คํจ: {e}") | |
| if st.session_state.get("sentiment:comp_drill_excel_ready"): | |
| st.download_button( | |
| label="๐พ ํ์ผ ์ ์ฅ", | |
| data=st.session_state["sentiment:comp_drill_excel"], | |
| file_name=f"competitor_keyword_{data['campaign_id']}.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| key="sentiment:comp_drill_dl_btn", | |
| ) | |
| def _export_competitor_drilldown( | |
| campaign_id: int, | |
| keyword: str | None = None, | |
| sentiment: str | None = None, | |
| platform: str | None = None, | |
| llm_is_negative: bool | None = None, | |
| llm_verified: str | None = None, | |
| ) -> bytes: | |
| """๊ฒฝ์์ฌ ๋๋ฆด๋ค์ด ๋ฐ์ดํฐ๋ฅผ Excel๋ก ์ถ์ถ (cursor pagination).""" | |
| from io import BytesIO | |
| from core.supabase_client import get_supabase_client | |
| sb = get_supabase_client() | |
| batch_size = 1000 | |
| last_id = 0 | |
| all_rows: list[dict] = [] | |
| while True: | |
| query = ( | |
| sb.table("keyword_sentiment_results") | |
| .select( | |
| "id, answer_id, keyword, matched_sentence, keyword_sentiment, keyword_confidence, " | |
| "competitor_brand_name, competitor_llm_verified, competitor_llm_sentiment, " | |
| "competitor_llm_is_negative, competitor_llm_confidence, " | |
| "competitor_llm_reason_tags, competitor_llm_reason_summary, " | |
| "citation_urls, citation_count, platform, question_content, created_at" | |
| ) | |
| .eq("campaign_id", campaign_id) | |
| .is_("brand_name", "null") | |
| .gt("id", last_id) | |
| ) | |
| # competitor_llm_verified filter (conditional, matching drilldown) | |
| if llm_verified == "unverified": | |
| query = query.or_("competitor_llm_verified.is.null,competitor_llm_verified.eq.false") | |
| else: | |
| query = query.eq("competitor_llm_verified", True) | |
| if keyword: | |
| query = query.eq("keyword", keyword) | |
| if sentiment: | |
| query = query.eq("keyword_sentiment", sentiment) | |
| if platform: | |
| query = query.eq("platform", platform) | |
| if llm_is_negative is not None: | |
| query = query.eq("competitor_llm_is_negative", llm_is_negative) | |
| result = query.order("id").limit(batch_size).execute() | |
| rows = result.data or [] | |
| if not rows: | |
| break | |
| all_rows.extend(rows) | |
| last_id = rows[-1]["id"] | |
| if len(rows) < batch_size: | |
| break | |
| from openpyxl import Workbook | |
| from openpyxl.utils import get_column_letter | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.title = "Competitor Keywords" | |
| headers = [ | |
| "ํค์๋", "๋งค์นญ ๋ฌธ์ฅ", "ํค์๋ ๊ฐ์ฑ", "ํค์๋ ํ์ ๋", | |
| "๊ฒฝ์์ฌ ๋ธ๋๋", "๊ฒฝ์์ฌ LLM ๊ฐ์ฑ", "๊ฒฝ์์ฌ ์ ํ/์คํ", | |
| "๊ฒฝ์์ฌ LLM ํ์ ๋", "๊ฒฝ์์ฌ LLM ํ๊ทธ", "๊ฒฝ์์ฌ LLM ์์ฝ", | |
| "์ธ์ฉ URL", "์ธ์ฉ ์", "ํ๋ซํผ", "์ง๋ฌธ", "Answer ID", "์์ฑ์ผ", | |
| ] | |
| ws.append(headers) | |
| def _tags_str(tags): | |
| if tags and isinstance(tags, list): | |
| return ", ".join(str(t) for t in tags) | |
| return "" | |
| for row in all_rows: | |
| cites = row.get("citation_urls") | |
| cite_str = "\n".join(str(u) for u in cites[:10]) if cites and isinstance(cites, list) else "" | |
| neg = row.get("competitor_llm_is_negative") | |
| neg_label = "์ ํ" if neg is True else "์คํ" if neg is False else "" | |
| ws.append([ | |
| row.get("keyword", ""), | |
| row.get("matched_sentence", ""), | |
| row.get("keyword_sentiment", ""), | |
| row.get("keyword_confidence"), | |
| row.get("competitor_brand_name", ""), | |
| row.get("competitor_llm_sentiment", ""), | |
| neg_label, | |
| row.get("competitor_llm_confidence"), | |
| _tags_str(row.get("competitor_llm_reason_tags")), | |
| row.get("competitor_llm_reason_summary", ""), | |
| cite_str, | |
| row.get("citation_count"), | |
| row.get("platform", ""), | |
| row.get("question_content", ""), | |
| row.get("answer_id"), | |
| (row.get("created_at") or "")[:19].replace("T", " "), | |
| ]) | |
| widths = [12, 50, 10, 8, 15, 10, 8, 8, 25, 30, 40, 6, 10, 40, 10, 16] | |
| for i, w in enumerate(widths, 1): | |
| ws.column_dimensions[get_column_letter(i)].width = w | |
| buf = BytesIO() | |
| wb.save(buf) | |
| return buf.getvalue() | |