"""키워드 드릴다운 — 자사/경쟁사 키워드 상세 목록 + 페이지네이션.""" 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'' f'🏢 {html.escape(brand)} {stats["total"]:,}건 ' f'긍정{pos_rate:.0f}% ' f'부정{neg_rate:.0f}%' ) st.markdown(f"""
경쟁사 브랜드 요약 — 총 {total_mentions:,}건 언급, {len(brand_stats)}개 브랜드
{' '.join(brand_chips)}
""", 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()