"""기회 영역 랭킹 테이블 + 상세 Expander. Clusters sorted by opportunity_score DESC. Top-10 with expanders showing sample fanouts and top sources. v9.0: 비즈니스 해석, percentile, 인용 유무 메시지 추가. """ import statistics import streamlit as st import pandas as pd import plotly.graph_objects as go def _get_percentile_rank(value: float, all_values: list[float]) -> int: """Return the percentile rank (0-100) of a value within a list.""" if not all_values: return 0 count_below = sum(1 for v in all_values if v < value) return int(count_below / len(all_values) * 100) def _interpret_density(density: float, median_density: float) -> str: """Interpret density relative to median.""" if density < median_density * 0.5: return "경쟁 낮음" elif density < median_density * 1.5: return "경쟁 보통" return "경쟁 높음" def _interpret_opportunity(opp: float, median_opp: float) -> str: """Interpret opportunity score.""" if opp >= median_opp * 1.5: return "기회 큼" elif opp >= median_opp * 0.5: return "기회 보통" return "기회 낮음" def render_opportunities(clusters: list[dict], frame: str = "all"): """Render opportunity ranking table with detail expanders.""" scored = [ c for c in clusters if c.get("opportunity_score") is not None and c.get("attention_score") is not None ] if not scored: st.info("스코어가 계산된 클러스터가 없습니다.") return # Sort by opportunity_score DESC scored.sort(key=lambda c: float(c.get("opportunity_score", 0) or 0), reverse=True) # Pre-compute statistics for percentile/interpretation all_attns = [float(c.get("attention_score", 0) or 0) for c in scored] all_densities = [float(c.get("citation_density", 0) or 0) for c in scored] all_opps = [float(c.get("opportunity_score", 0) or 0) for c in scored] median_density = statistics.median(all_densities) if all_densities else 0 median_opp = statistics.median(all_opps) if all_opps else 0 # Top metrics col1, col2, col3 = st.columns(3) with col1: st.metric("스코어링 완료", f"{len(scored)}개 클러스터") with col2: avg_opp = sum(all_opps) / len(all_opps) st.metric("평균 Opportunity", f"{avg_opp:.4f}") with col3: top = scored[0] st.metric("Top 기회 토픽", (top.get("cluster_label") or "N/A")[:25]) st.markdown("---") # Explanation before ranking table (frame-specific) if frame == "demand": st.info( "ChatGPT가 자주 검색하지만 경쟁이 낮은 Demand 토픽입니다.\n\n" "기회 점수가 높을수록, 이 주제에 콘텐츠를 만들면 ChatGPT 검색에 노출될 가능성이 높습니다." ) elif frame == "supply": st.info( "Gemini가 자주 인용하지만 출처 경쟁이 낮은 Supply 토픽입니다.\n\n" "기회 점수가 높을수록, 이 주제에 콘텐츠를 만들면 Gemini 답변에 인용될 가능성이 높습니다." ) else: st.info( "기회 점수 Top 10 토픽을 상세 분석합니다. " "기회 점수는 **'AI 관심도가 높지만 경쟁(인용 출처)이 적은 토픽'**을 찾아주는 지표입니다.\n\n" "점수가 높을수록 콘텐츠를 만들면 AI에 인용될 가능성이 높습니다." ) # Ranking table rows = [] for i, c in enumerate(scored[:50], 1): rows.append({ "순위": i, "토픽": (c.get("cluster_label") or f"Cluster-{c['id'][:8]}")[:40], "Attention": f"{float(c.get('attention_score', 0) or 0):.4f}", "Density": f"{float(c.get('citation_density', 0) or 0):.4f}", "Opportunity": f"{float(c.get('opportunity_score', 0) or 0):.4f}", "Fanouts": c.get("fanout_count", 0), }) df = pd.DataFrame(rows) st.dataframe(df, use_container_width=True, hide_index=True) # Detail expanders for top 10 st.markdown("#### Top 10 상세") for i, c in enumerate(scored[:10], 1): label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}" opp = float(c.get("opportunity_score", 0) or 0) with st.expander(f"#{i} {label} (Opportunity: {opp:.4f})", key=f"research:opp_detail:{c['id']}"): detail_col1, detail_col2 = st.columns(2) with detail_col1: attn = float(c.get("attention_score", 0) or 0) density = float(c.get("citation_density", 0) or 0) attn_pct = _get_percentile_rank(attn, all_attns) density_label = _interpret_density(density, median_density) opp_label = _interpret_opportunity(opp, median_opp) st.markdown("**Score 상세**") st.write(f"- Attention: {attn:.4f} (상위 {100 - attn_pct}%)") st.write(f"- Density: {density:.4f} ({density_label})") st.write(f"- Opportunity: {opp:.4f} ({opp_label})") st.write(f"- Fanout 수: {c.get('fanout_count', 0)}") if c.get("unique_questions"): st.write(f"- 관련 질문 수: {c['unique_questions']}") with detail_col2: # Sample fanouts/citations with context samples = c.get("sample_fanouts") or [] if samples: if frame == "supply": st.markdown("**대표 인용 문구** (Gemini가 실제로 인용한 텍스트):") else: st.markdown("**대표 AI 추가 질문** (이 토픽에서 AI가 실제로 생성한 질문들):") for s in samples[:5]: st.write(f"- {s}") if frame == "supply": st.caption("이런 형태의 콘텐츠를 만들면 Gemini 답변에 인용될 수 있습니다.") else: st.caption("이런 질문에 대한 콘텐츠를 만들면 AI 답변에 인용될 수 있습니다.") else: st.write("샘플 없음") # Top sources bar chart top_sources = c.get("top_sources") if top_sources and isinstance(top_sources, list) and len(top_sources) > 0: st.caption("이 토픽에서 AI가 인용한 주요 출처입니다. 여기에 자사 콘텐츠가 없다면 진출 기회입니다.") domains = [s.get("host_url", "unknown") for s in top_sources[:10]] counts = [s.get("count", 0) for s in top_sources[:10]] fig = go.Figure(go.Bar( x=counts, y=domains, orientation="h", marker_color="#059669", )) fig.update_layout( title="Top 인용 출처", xaxis_title="인용 수", yaxis=dict(autorange="reversed"), height=300, margin=dict(l=0, r=0, t=30, b=0), template="plotly_white", ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) else: st.info("이 토픽은 아직 AI가 특정 출처를 인용하지 않고 있어, 콘텐츠 선점 기회가 더욱 큽니다.")