chainshift-dashboard / features /research /content_actions.py
GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
9.1 kB
"""์ฝ˜ํ…์ธ  ์•ก์…˜ ๊ฐ€์ด๋“œ (R-5).
GapScore OPPORTUNITY ํ† ํ”ฝ ๊ธฐ๋ฐ˜ ์ฝ˜ํ…์ธ  ์ œ์ž‘ ์ œ์•ˆ.
Cross-Model ๋ถ„์„ ๋ฐ์ดํ„ฐ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๊ตฌ์ฒด์  ์•ก์…˜ ์•„์ดํ…œ ์ƒ์„ฑ.
"""
import streamlit as st
import pandas as pd
from core.supabase_client import get_gap_scores, get_topic_clusters
# Content strategy templates per quadrant
STRATEGY_TEMPLATES = {
"OPPORTUNITY": {
"priority": "๐Ÿ”ด ๋†’์Œ",
"action": "์ฝ˜ํ…์ธ  ์ œ์ž‘",
"detail": (
"์‚ฌ์šฉ์ž๊ฐ€ ์ž์ฃผ ๊ฒ€์ƒ‰ํ•˜์ง€๋งŒ AI๊ฐ€ ์ธ์šฉํ•  ๋งŒํ•œ ์ฝ˜ํ…์ธ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค. "
"์ด ํ† ํ”ฝ์— ๋Œ€ํ•œ ์ „๋ฌธ ์ฝ˜ํ…์ธ ๋ฅผ ์ œ์ž‘ํ•˜๋ฉด AI ๋‹ต๋ณ€์— ์ธ์šฉ๋  ํ™•๋ฅ ์ด ๋†’์Šต๋‹ˆ๋‹ค."
),
"tactics": [
"FAQ ํŽ˜์ด์ง€์— ์ด ํ† ํ”ฝ ๊ด€๋ จ ์งˆ๋ฌธ-๋‹ต๋ณ€ ์ถ”๊ฐ€",
"ํ†ต๊ณ„/๋ฐ์ดํ„ฐ ํฌํ•จ โ€” AI ์ธ์šฉ ํ™•๋ฅ  +41% (GEO ์—ฐ๊ตฌ)",
"30-50๋‹จ์–ด ์ž๊ธฐ ์™„๊ฒฐํ˜• ๋‹ต๋ณ€ ๋ฌธ๋‹จ ํฌํ•จ (Answer Capsule)",
"Schema Markup ์ถ”๊ฐ€ โ€” AI ์ธ์šฉ ํ™•๋ฅ  2.5๋ฐฐ ์ฆ๊ฐ€",
],
},
"SATURATED": {
"priority": "๐ŸŸก ์ค‘๊ฐ„",
"action": "์ฐจ๋ณ„ํ™” ๊ฐ•ํ™”",
"detail": (
"์ˆ˜์š”์™€ ๊ณต๊ธ‰ ๋ชจ๋‘ ๋†’์€ ๊ฒฝ์Ÿ ํ† ํ”ฝ์ž…๋‹ˆ๋‹ค. "
"๊ธฐ์กด ์ฝ˜ํ…์ธ ์™€ ์ฐจ๋ณ„ํ™”๋œ ์ „๋ฌธ์„ฑ์ด๋‚˜ ๊ณ ์œ  ๋ฐ์ดํ„ฐ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
),
"tactics": [
"์ž์ฒด ์—ฐ๊ตฌ ๋ฐ์ดํ„ฐ/์ผ€์ด์Šค ์Šคํ„ฐ๋”” ์ถ”๊ฐ€",
"๊ธฐ์กด ์ธ์šฉ ์†Œ์Šค ๋ถ„์„ โ†’ ๋น ์ง„ ๊ฐ๋„(angle) ๋ฐœ๊ตด",
"E-E-A-T ์‹ ํ˜ธ ๊ฐ•ํ™” (์ €์ž ์ „๋ฌธ์„ฑ, ์ธ์šฉ ์ถœ์ฒ˜ ๋ช…์‹œ)",
"๋น„๊ตํ‘œ/๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”๋กœ ์ •๋ณด ๋ฐ€๋„ ๋†’์ด๊ธฐ",
],
},
"LATENT_AUTHORITY": {
"priority": "๐ŸŸข ๋‚ฎ์Œ",
"action": "์œ ์ง€ + ๋ชจ๋‹ˆํ„ฐ๋ง",
"detail": (
"์ด๋ฏธ AI์— ์ธ์šฉ๋˜๊ณ  ์žˆ์ง€๋งŒ ๊ฒ€์ƒ‰ ์ˆ˜์š”๊ฐ€ ๋‚ฎ์Šต๋‹ˆ๋‹ค. "
"๊ธฐ์กด ์ฝ˜ํ…์ธ ๋ฅผ ์œ ์ง€ํ•˜๋ฉฐ ์ˆ˜์š” ๋ณ€ํ™”๋ฅผ ๋ชจ๋‹ˆํ„ฐ๋งํ•˜์„ธ์š”."
),
"tactics": [
"๊ธฐ์กด ์ธ์šฉ ์ฝ˜ํ…์ธ ์˜ ์ตœ์‹  ์—…๋ฐ์ดํŠธ ์œ ์ง€",
"Demand ์ฆ๊ฐ€ ์ถ”์„ธ ๊ฐ์ง€ ์‹œ ์ฝ˜ํ…์ธ  ํ™•์žฅ",
"์ธ์šฉ๋˜๋Š” ๊ตฌ์ฒด์  ๋ฌธ์žฅ/๊ตฌ์ ˆ ํŒŒ์•… โ†’ ๊ฐ•ํ™”",
],
},
"NICHE": {
"priority": "โšช ๊ด€๋ง",
"action": "์„ ํƒ์  ์‹คํ—˜",
"detail": (
"์ˆ˜์š”์™€ ๊ณต๊ธ‰ ๋ชจ๋‘ ๋‚ฎ์€ ํ‹ˆ์ƒˆ ์˜์—ญ์ž…๋‹ˆ๋‹ค. "
"์‹œ์žฅ์ด ์„ฑ์žฅํ•˜๋ฉด ์„ ์  ํšจ๊ณผ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
),
"tactics": [
"๋‚ฎ์€ ๋น„์šฉ์œผ๋กœ ๊ธฐ๋ณธ ์ฝ˜ํ…์ธ  ๋งˆ๋ จ (์„ ์ )",
"๊ด€๋ จ ํ‚ค์›Œ๋“œ ํŠธ๋ Œ๋“œ ๋ชจ๋‹ˆํ„ฐ๋ง",
],
},
}
def render_content_actions(base_ctx: dict, pair: dict):
"""Render content action guide based on GapScore analysis."""
campaign_chatgpt = pair["campaign_chatgpt"]
campaign_gemini = pair["campaign_gemini"]
st.caption(
"Demand-Supply Gap ๋ถ„์„ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ **๊ตฌ์ฒด์  ์ฝ˜ํ…์ธ  ์ „๋žต**์„ ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค."
)
matches = get_gap_scores(campaign_chatgpt, campaign_gemini)
if not matches:
st.warning("GapScore ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
return
# Fetch cluster details for sample_fanouts and top_sources
chatgpt_clusters = get_topic_clusters(campaign_chatgpt, source="chatgpt")
gemini_clusters = get_topic_clusters(campaign_gemini, source="gemini")
chatgpt_map = {c["id"]: c for c in chatgpt_clusters}
gemini_map = {c["id"]: c for c in gemini_clusters}
# --- Overview: Quadrant distribution ---
quadrant_counts = {}
for m in matches:
q = m.get("quadrant", "NICHE")
quadrant_counts[q] = quadrant_counts.get(q, 0) + 1
opp_count = quadrant_counts.get("OPPORTUNITY", 0)
sat_count = quadrant_counts.get("SATURATED", 0)
col1, col2, col3 = st.columns(3)
with col1:
st.metric("์ฝ˜ํ…์ธ  ์ œ์ž‘ ํ•„์š”", f"{opp_count}๊ฐœ ํ† ํ”ฝ",
help="OPPORTUNITY: Demand ๋†’์Œ + Supply ๋‚ฎ์Œ")
with col2:
st.metric("์ฐจ๋ณ„ํ™” ํ•„์š”", f"{sat_count}๊ฐœ ํ† ํ”ฝ",
help="SATURATED: Demand ๋†’์Œ + Supply ๋†’์Œ")
with col3:
st.metric("์ด ๋ถ„์„ ํ† ํ”ฝ", f"{len(matches)}๊ฐœ")
st.markdown("---")
# --- Priority Action List ---
st.markdown("### ์šฐ์„  ์•ก์…˜ ๋ฆฌ์ŠคํŠธ")
st.caption("GapScore ์ˆœ์œผ๋กœ ์ •๋ ฌ. OPPORTUNITY ํ† ํ”ฝ์ด ์ตœ์šฐ์„ ์ž…๋‹ˆ๋‹ค.")
# Summary table
rows = []
for i, m in enumerate(matches, 1):
q = m.get("quadrant", "NICHE")
strategy = STRATEGY_TEMPLATES.get(q, STRATEGY_TEMPLATES["NICHE"])
rows.append({
"์ˆœ์œ„": i,
"ํ† ํ”ฝ (Demand)": (m.get("chatgpt_label") or "")[:35],
"ํ† ํ”ฝ (Supply)": (m.get("gemini_label") or "")[:35],
"GapScore": f"{float(m.get('gap_score', 0)):.4f}",
"Quadrant": q.replace("_", " ").title(),
"์•ก์…˜": strategy["action"],
"์šฐ์„ ์ˆœ์œ„": strategy["priority"],
})
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
st.markdown("---")
# --- Detailed Action Cards for Top OPPORTUNITY topics ---
opportunity_matches = [m for m in matches if m.get("quadrant") == "OPPORTUNITY"]
if opportunity_matches:
st.markdown("### OPPORTUNITY ํ† ํ”ฝ ์ƒ์„ธ ๊ฐ€์ด๋“œ")
st.caption(
"์ฝ˜ํ…์ธ  ์ œ์ž‘ ROI๊ฐ€ ๊ฐ€์žฅ ๋†’์€ ํ† ํ”ฝ์ž…๋‹ˆ๋‹ค. "
"์‚ฌ์šฉ์ž๊ฐ€ ์ž์ฃผ ๋ฌป์ง€๋งŒ AI๊ฐ€ ์ธ์šฉํ•  ์ฝ˜ํ…์ธ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."
)
for i, m in enumerate(opportunity_matches[:10], 1):
chatgpt_label = m.get("chatgpt_label", "Unknown")
gap = float(m.get("gap_score") or 0)
demand = float(m.get("demand_percentile") or 0)
supply = float(m.get("supply_percentile") or 0)
chatgpt_detail = chatgpt_map.get(m["chatgpt_cluster_id"], {})
gemini_detail = gemini_map.get(m["gemini_cluster_id"], {})
with st.expander(f"#{i} {chatgpt_label} (GapScore: {gap:.4f})", key=f"research:opp_action:{m['id']}"):
left, right = st.columns(2)
with left:
st.markdown("**Gap ๋ถ„์„**")
st.write(f"- Demand (ChatGPT): {demand:.0%}")
st.write(f"- Supply (Gemini): {supply:.0%}")
st.write(f"- Gap: Demand {demand:.0%} vs Supply {supply:.0%}")
# Sample fanouts = what users ask
samples = chatgpt_detail.get("sample_fanouts") or []
if samples:
st.markdown("**์‚ฌ์šฉ์ž๊ฐ€ ๋ฌป๋Š” ์งˆ๋ฌธ๋“ค:**")
for s in samples[:5]:
st.write(f" - {s}")
with right:
st.markdown("**์ฝ˜ํ…์ธ  ์ „๋žต**")
strategy = STRATEGY_TEMPLATES["OPPORTUNITY"]
st.info(strategy["detail"])
st.markdown("**๊ตฌ์ฒด์  ์‹คํ–‰ ํ•ญ๋ชฉ:**")
for tactic in strategy["tactics"]:
st.write(f"- {tactic}")
# Show Gemini citation examples if available
gemini_samples = gemini_detail.get("sample_fanouts") or []
if gemini_samples:
st.markdown("**AI๊ฐ€ ํ˜„์žฌ ์ธ์šฉํ•˜๋Š” ๋ฌธ๊ตฌ ์˜ˆ์‹œ:**")
for s in gemini_samples[:3]:
st.write(f" > {s}")
st.caption("์ด๋Ÿฐ ํ˜•ํƒœ์˜ ๋ฌธ์žฅ์„ ์ฝ˜ํ…์ธ ์— ํฌํ•จํ•˜์„ธ์š”.")
# Show top sources if available
top_sources = gemini_detail.get("top_sources")
if top_sources and isinstance(top_sources, list):
domains = [s.get("host_url", s.get("url", "")) for s in top_sources[:5]]
if domains:
st.markdown("**ํ˜„์žฌ AI ์ธ์šฉ ์ถœ์ฒ˜:**")
for d in domains:
st.write(f" - {d}")
st.caption("์ด ์ถœ์ฒ˜๋“ค์ด ๋‹ค๋ฃจ์ง€ ์•Š๋Š” ๊ฐ๋„๋ฅผ ์ฐพ์œผ์„ธ์š”.")
else:
st.success("๋ชจ๋“  ํ† ํ”ฝ์— ์ถฉ๋ถ„ํ•œ Supply๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฐจ๋ณ„ํ™” ์ „๋žต์— ์ง‘์ค‘ํ•˜์„ธ์š”.")
# --- SATURATED topics brief ---
saturated_matches = [m for m in matches if m.get("quadrant") == "SATURATED"]
if saturated_matches:
st.markdown("---")
st.markdown("### SATURATED ํ† ํ”ฝ ์š”์•ฝ")
st.caption("์ฐจ๋ณ„ํ™”๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์Ÿ ํ† ํ”ฝ์ž…๋‹ˆ๋‹ค.")
for i, m in enumerate(saturated_matches[:5], 1):
label = m.get("chatgpt_label", "Unknown")
gap = float(m.get("gap_score", 0))
with st.expander(f"#{i} {label} (GapScore: {gap:.4f})", key=f"research:sat_action:{m['id']}"):
strategy = STRATEGY_TEMPLATES["SATURATED"]
st.info(strategy["detail"])
st.markdown("**์‹คํ–‰ ํ•ญ๋ชฉ:**")
for tactic in strategy["tactics"]:
st.write(f"- {tactic}")