feat(kpi): 규모별 성장률 공식으로 KPI dashboard 코드 산출

Perplexity Sonar가 KPI target schema 필드를 구조적으로 못 채우는 한계 검증됨 (프롬프트 강화·sonar-pro·sonar-reasoning-pro·hint 주입 다 실패). mockup 7개(irum/grand/o2o/ts/banobagi/wonjin/viewclinic) 역분석으로 추출한 채널 규모별 성장률 공식을 코드에서 결정적으로 산출 → 100% 재현성 확보.

- kpi_dashboard.py(신규): _target_multiplier 4단계 + _blog_frequency cadence + 강남언니 리뷰 보수적 multiplier
- 8 metric 산출: YouTube 구독자 / Instagram KR·EN 팔로워 / Facebook KR·EN 팔로워 / TikTok 팔로워 / Naver Cafe 회원 / 네이버 블로그 포스팅 빈도 / 강남언니 리뷰
- analysis.py: _build_overrides에서 build_kpi_dashboard 호출, _patch_report에서 LLM 출력 무시하고 코드값 강제
- common/utils.parse_ts: facebook_audit._parse_ts 옮겨 공용화 (FB·블로그 RSS 둘 다 사용). ISO 8601 / epoch / RFC 2822(네이버 RSS) 통합 처리
- report_prompt: kpi_dashboard는 코드 강제 치환 안내 + overall_score는 channel_scores 평균으로 0/null 금지 가드 추가

mockup viewclinic YT 구독자 104K→115K→200K 정확 일치 검증. 라이프사이클 4단계로 같은 raw_data 입력 시 매번 동일 output 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
Mina Choi 2026-06-01 15:45:06 +09:00
parent e5a9036e47
commit 86af23b56d
5 changed files with 127 additions and 12 deletions

View File

@ -1,6 +1,7 @@
import os import os
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone
from http import HTTPMethod from http import HTTPMethod
import httpx import httpx
@ -9,6 +10,27 @@ logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = 60 REQUEST_TIMEOUT = 60
def parse_ts(v) -> datetime | None:
"""수집기마다 다른 timestamp 포맷을 통일된 datetime으로 변환.
파싱 실패 None.
"""
# 숫자면 epoch (Unix timestamp) — apify가 가끔 epoch로 줌
if isinstance(v, (int, float)):
return datetime.fromtimestamp(v, tz=timezone.utc)
if isinstance(v, str):
# 1순위: ISO 8601 (대부분 apify/firecrawl 출력)
try:
return datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
pass
# 2순위: RFC 2822 (네이버 블로그 RSS 등 — 표준 라이브러리 파서로)
try:
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(v)
except (TypeError, ValueError):
return None
return None
def get_env(key: str) -> str: def get_env(key: str) -> str:
v = os.environ.get(key, "") v = os.environ.get(key, "")

View File

@ -111,5 +111,5 @@
- 데이터가 null인 계정은 항목을 만들지 마세요. icon은 instagram/facebook/video 등 플랫폼에 맞게 설정. - 데이터가 null인 계정은 항목을 만들지 마세요. icon은 instagram/facebook/video 등 플랫폼에 맞게 설정.
- strengths와 weaknesses는 각 3개 이상 작성하세요. - strengths와 weaknesses는 각 3개 이상 작성하세요.
- roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요. - roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요.
- kpis는 실제 수집된 수치 기반으로 현실적인 측정 가능 지표로 작성하세요. - kpi_dashboard는 코드가 결정적으로 산출해 후처리 강제 치환하므로 LLM 출력 무시됩니다. 빈 배열 또는 placeholder로 두세요.
- conversion_strategy의 actions는 구체적인 실행 방안으로 작성하세요. - conversion_strategy의 actions는 구체적인 실행 방안으로 작성하세요.

View File

@ -8,6 +8,7 @@ from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosi
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit
from services.instagram_audit import build_instagram_accounts from services.instagram_audit import build_instagram_accounts
from services.facebook_audit import build_facebook_pages from services.facebook_audit import build_facebook_pages
from services.kpi_dashboard import build_kpi_dashboard
from integrations.llm.schemas.plan import PlanOutput from integrations.llm.schemas.plan import PlanOutput
from models.status import AnalysisStatus from models.status import AnalysisStatus
@ -287,6 +288,8 @@ async def _build_overrides(analysis_run_id: str) -> dict:
# ── facebook (KR=facebook_data, EN=hospital.facebookEn 둘 다 코드 산출, [KR, EN] 순서) ── # ── facebook (KR=facebook_data, EN=hospital.facebookEn 둘 다 코드 산출, [KR, EN] 순서) ──
fb_pages = build_facebook_pages(facebook, hospital.get("facebookEn") or {}) fb_pages = build_facebook_pages(facebook, hospital.get("facebookEn") or {})
# ── KPI dashboard: 7개 mockup 라이프사이클 공식으로 코드가 결정. LLM 출력은 무시. ──────
kpi = build_kpi_dashboard(instagram, facebook, youtube, gangnam_unni, hospital, naver_blog)
overrides: dict = {} overrides: dict = {}
if snapshot: if snapshot:
@ -297,6 +300,8 @@ async def _build_overrides(analysis_run_id: str) -> dict:
overrides["facebook_audit"] = {"pages": fb_pages} overrides["facebook_audit"] = {"pages": fb_pages}
if yt_patch: if yt_patch:
overrides["youtube_audit"] = yt_patch overrides["youtube_audit"] = yt_patch
if kpi:
overrides["kpi_dashboard"] = kpi
return overrides return overrides
@ -328,6 +333,9 @@ def _patch_report(result: ReportOutput, overrides: dict) -> ReportOutput:
for i, patch in enumerate(fb_pages): for i, patch in enumerate(fb_pages):
if i < len(base_pages): if i < len(base_pages):
base_pages[i].update(patch) base_pages[i].update(patch)
# KPI dashboard 강제 치환 — 코드가 계산한 라이프사이클 공식 그대로.
if overrides.get("kpi_dashboard"):
merged["kpi_dashboard"] = overrides["kpi_dashboard"]
return ReportOutput(**merged) return ReportOutput(**merged)

View File

@ -4,16 +4,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from common.utils import parse_ts
def _parse_ts(v) -> datetime | None:
if isinstance(v, (int, float)):
return datetime.fromtimestamp(v, tz=timezone.utc)
if isinstance(v, str):
try:
return datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
return None
return None
def _humanize_age(days: int) -> str: def _humanize_age(days: int) -> str:
@ -66,7 +57,7 @@ def transform_for_storage(fb: dict | None) -> dict | None:
posts = fb.get("latestPosts") or [] posts = fb.get("latestPosts") or []
out = {k: v for k, v in fb.items() if k != "latestPosts"} out = {k: v for k, v in fb.items() if k != "latestPosts"}
if posts: if posts:
dts = sorted((d for d in (_parse_ts(p.get("timestamp")) for p in posts) if d), reverse=True) dts = sorted((d for d in (parse_ts(p.get("timestamp")) for p in posts) if d), reverse=True)
if dts: if dts:
out["recent_post_age"] = _humanize_age((datetime.now(timezone.utc) - dts[0]).days) out["recent_post_age"] = _humanize_age((datetime.now(timezone.utc) - dts[0]).days)
if len(dts) > 1: if len(dts) > 1:

View File

@ -0,0 +1,94 @@
"""mockup 7개 역분석 — 채널 규모별 3개월/12개월 target 성장률 공식."""
def _round_clean(n: int) -> int:
if n < 100: return n
if n < 1000: return round(n / 100) * 100
if n < 10_000: return round(n / 500) * 500
if n < 100_000: return round(n / 1000) * 1000
if n < 1_000_000: return round(n / 5000) * 5000
return round(n / 50_000) * 50_000
def _target_multiplier(current: int) -> tuple[float, float]:
if current < 1_000: return (2.5, 9.0)
if current < 5_000: return (1.7, 4.0)
if current < 25_000: return (1.5, 2.5)
if current < 50_000: return (1.3, 2.2)
return (1.1, 1.9)
def _follower_kpi(metric: str, val: int | None, unit: str = "") -> dict | None:
if not val: return None
m3, m12 = _target_multiplier(val)
return {
"metric": metric,
"current": f"{val:,}{unit}",
"target_3_month": f"{_round_clean(int(val * m3)):,}{unit}",
"target_12_month": f"{_round_clean(int(val * m12)):,}{unit}",
}
def _blog_frequency(posts: list) -> tuple[str, str, str] | None:
"""RSS posts timestamp로 (current, target_3m, target_12m) 라벨 반환. target은 절대 downgrade 안 함."""
from common.utils import parse_ts
dts = sorted((d for d in (parse_ts(p.get("postDate")) for p in posts) if d), reverse=True)
if len(dts) < 2: return None
avg_gap = (dts[0] - dts[-1]).days / (len(dts) - 1)
if avg_gap > 90: current = f"방치 ({dts[0].strftime('%Y-%m')})"
elif avg_gap <= 1: current = f"{7 // max(int(avg_gap), 1)}"
elif avg_gap <= 3: current = "주 2~3회"
elif avg_gap <= 14: current = "주 1~2회"
elif avg_gap <= 30: current = f"{max(30 // int(avg_gap), 1)}"
else: current = "월 1회 미만"
if avg_gap > 3: return current, "주 2회", "주 3회"
if avg_gap > 2: return current, "주 3회", "주 5회"
if avg_gap > 1: return current, "주 5회", "주 7회"
return current, f"{current} 유지", f"{current} 유지"
def build_kpi_dashboard(
instagram: dict, facebook: dict, youtube: dict, gangnam_unni: dict, hospital: dict,
naver_blog: dict | None = None,
) -> list[dict]:
ig_en = hospital.get("instagramEn") or {}
fb_en = hospital.get("facebookEn") or {}
tiktok = hospital.get("tiktok") or {}
cafe = hospital.get("naverCafe") or {}
kpis: list[dict] = []
for k in [
_follower_kpi("YouTube 구독자", youtube.get("subscribers")),
_follower_kpi("Instagram KR 팔로워", instagram.get("followers")),
_follower_kpi("Instagram EN 팔로워", ig_en.get("followers")),
_follower_kpi("Facebook KR 팔로워", facebook.get("followers")),
_follower_kpi("Facebook EN 팔로워", fb_en.get("followers")),
_follower_kpi("TikTok 팔로워", tiktok.get("followers")),
_follower_kpi("Naver Cafe 회원 수", cafe.get("memberCount")),
]:
if k: kpis.append(k)
if naver_blog:
freq = _blog_frequency(naver_blog.get("posts") or [])
if freq:
cur, t3, t12 = freq
kpis.append({
"metric": "네이버 블로그 포스팅 빈도",
"current": cur,
"target_3_month": t3,
"target_12_month": t12,
})
gu_reviews = gangnam_unni.get("totalReviews")
if gu_reviews:
if gu_reviews < 1000: rm3, rm12 = 2.0, 6.0
elif gu_reviews < 5000: rm3, rm12 = 1.10, 1.50
else: rm3, rm12 = 1.07, 1.27
kpis.append({
"metric": "강남언니 리뷰",
"current": f"{gu_reviews:,}",
"target_3_month": f"{_round_clean(int(gu_reviews * rm3)):,}",
"target_12_month": f"{_round_clean(int(gu_reviews * rm12)):,}",
})
return kpis