네이버 AI 브리핑은 공식 API가 없어 지금까지 답변 노출을 재지 못했다. SerpApi naver_ai_overview(제3자 SERP API)로 조회하는 엔진을 붙였다(haewon 결정 2026-09-15). search.naver.com robots.txt 가 수집을 금지하므로 고객 리포트·랜딩에는 넣지 않고 내부 측정까지만 반영했다. 고객 노출은 법무 확인 뒤에 따로 한다. - run_question_bank_openai.py: --engine naver_briefing, --device, --no-cache(반복 측정 시 1시간 캐시 무시). 브리핑이 없는 문항은 answer 를 비우고 briefing=false 로 기록. 인용 출처(sources) 보관 - monthly_ai_measure.py: 기본 엔진 3개(키 없으면 네이버 건너뜀), supporter_builds.report.naverBriefing(노출률·언급·출처 상위, legalReviewed=false) - sentiment_qb.py: 빈 답변은 판정 API를 부르지 않고 미언급 처리 - merge_qb_results.py: NAVER 열·브리핑 없음 표시, 측정일을 생성일이 아닌 엔진별 실제 날짜로 - export_qb_excel.py: 네이버 열·NAVER상세 시트, --out 으로 기존 파일 보존. TOP50 점수는 기존 정의(ChatGPT+Perplexity, 만점 840) 유지 실측(뷰성형외과, 모바일, 2026-09-15): 브리핑 노출 119/120, 브랜드 질문 언급 36/36, 논브랜드 1/84. 인용 출처는 네이버 블로그 87·카페 51·굿닥 23·뷰 공식 20 순. SerpApi 123회, 감성 판정 $0.37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
8.7 KiB
Python
149 lines
8.7 KiB
Python
#!/usr/bin/env python3
|
|
"""월간 AI 실측 체인: 질문 뱅크 러너 → 감성 판정 → 리포트 → supporter_builds.report.sentiment.
|
|
|
|
핸드오버 §2-4 의 "한 줄". 지금까지 사람이 네 개 명령을 순서대로 치던 것을 하나로 묶는다.
|
|
사람 게이트는 자동화하지 않는다. 보정(라벨링)은 여기 들어가지 않으며, 확정 여부는
|
|
calibration.confirmed 플래그로 리포트 카드에 그대로 전달한다.
|
|
|
|
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과
|
|
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변 재사용, 감성만
|
|
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
|
|
|
|
한계: 질문 뱅크 러너(run_question_bank_openai.py)는 아직 뷰성형외과 전용이다
|
|
(scripts/question_bank_viewclinic.json 하드코딩). 다른 병원으로 넓히려면 러너를
|
|
먼저 병원별로 매개변수화해야 한다.
|
|
"""
|
|
import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error, importlib.util
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
OUT = os.path.join(ROOT, "scripts", "out")
|
|
ALL_ENGINES = ("openai", "perplexity", "naver_briefing")
|
|
# naver_briefing 은 SerpApi(제3자 SERP API)로 네이버 AI 브리핑을 조회한다(haewon 결정 2026-09-15).
|
|
# SERPAPI_API_KEY 가 없으면 건너뛴다. 결과를 고객 리포트에 쓰기 전 법무 확인이 필요하다(run_question_bank_openai.py 머리말).
|
|
|
|
spec = importlib.util.spec_from_file_location("sqb", os.path.join(ROOT, "scripts", "sentiment_qb.py"))
|
|
sqb = importlib.util.module_from_spec(spec); spec.loader.exec_module(sqb)
|
|
|
|
|
|
def sh(argv):
|
|
print(" $ " + " ".join(argv), flush=True)
|
|
r = subprocess.run(argv, cwd=ROOT)
|
|
if r.returncode != 0: sys.exit(f"실패: {' '.join(argv)}")
|
|
|
|
|
|
def naver_briefing_summary(path):
|
|
"""네이버 AI 브리핑 노출률·브리핑 내 언급률·인용 출처 상위. 브리핑이 없는 질문과 언급 없음을 나눠 센다."""
|
|
rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()]
|
|
shown = [r for r in rows if r.get("briefing")]
|
|
src = {}
|
|
for r in shown:
|
|
for s in {(x.get("source") or x.get("link") or "").strip() for x in r.get("sources") or []}:
|
|
if s: src[s] = src.get(s, 0) + 1
|
|
brand = lambda r: "뷰성형외과" in r.get("question", "")
|
|
nb_shown = [r for r in shown if not brand(r)]
|
|
return {
|
|
"source": "SerpApi naver_ai_overview", "device": rows[0].get("device") if rows else None,
|
|
"measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"questions": len(rows), "briefingShown": len(shown),
|
|
"mentionedInBriefing": sum(1 for r in shown if r.get("view_mentioned")),
|
|
"nonBrandBriefingShown": len(nb_shown),
|
|
"nonBrandMentioned": sum(1 for r in nb_shown if r.get("view_mentioned")),
|
|
"topSources": [{"source": k, "answers": v} for k, v in sorted(src.items(), key=lambda x: -x[1])[:10]],
|
|
"legalReviewed": False,
|
|
}
|
|
|
|
|
|
def patch_supabase(build_id, sentiment, extra=None):
|
|
"""supporter_builds.report 에 sentiment(와 extra 키)만 병합한다. 다른 단계가 쓴 키를 지우지 않는다."""
|
|
sqb.load_env()
|
|
url = os.environ.get("VITE_SUPABASE_URL"); key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
|
|
if not (url and key): sys.exit("VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 필요 (.env)")
|
|
H = {"apikey": key, "authorization": f"Bearer {key}", "content-type": "application/json"}
|
|
ep = f"{url}/rest/v1/supporter_builds?id=eq.{build_id}"
|
|
req = urllib.request.Request(ep + "&select=report", headers=H)
|
|
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
|
|
rows = json.loads(r.read().decode())
|
|
if not rows: sys.exit(f"supporter_builds 에 {build_id} 없음")
|
|
report = rows[0].get("report") or {}
|
|
report["sentiment"] = sentiment
|
|
for k, v in (extra or {}).items():
|
|
report[k] = v
|
|
body = json.dumps({"report": report, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S")}).encode()
|
|
req = urllib.request.Request(ep, data=body, headers={**H, "prefer": "return=minimal"}, method="PATCH")
|
|
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
|
|
print(f" supporter_builds.report 갱신: sentiment{''.join(' · ' + k for k in (extra or {}))} (HTTP {r.status})")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--clinic", default="뷰성형외과")
|
|
ap.add_argument("--alias", default="뷰성형외과의원,뷰 성형외과,View Plastic Surgery,viewclinic")
|
|
ap.add_argument("--report", default="docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md")
|
|
ap.add_argument("--build-id", help="주면 supporter_builds.report.sentiment 에 반영한다")
|
|
ap.add_argument("--skip-run", action="store_true", help="질문 뱅크 러너를 건너뛰고 기존 답변으로 감성만 다시 낸다")
|
|
ap.add_argument("--rejudge", action="store_true", help="기존 감성 판정을 지우고 다시 판정한다(기준표를 고친 뒤)")
|
|
ap.add_argument("--engines", default=",".join(ALL_ENGINES), help="쉼표 구분. 기본: openai,perplexity,naver_briefing")
|
|
a = ap.parse_args()
|
|
sqb.load_env()
|
|
ENGINES = [e for e in a.engines.split(",") if e in ALL_ENGINES]
|
|
if "naver_briefing" in ENGINES and not os.environ.get("SERPAPI_API_KEY"):
|
|
print(" SERPAPI_API_KEY 없음. naver_briefing 은 건너뛴다.")
|
|
ENGINES.remove("naver_briefing")
|
|
|
|
if not a.skip_run:
|
|
print("[1/3] 질문 뱅크 실측")
|
|
for e in ENGINES:
|
|
sh([sys.executable, "scripts/run_question_bank_openai.py", "--engine", e])
|
|
else:
|
|
print("[1/3] 질문 뱅크 실측 건너뜀 (--skip-run)")
|
|
|
|
print("[2/3] 감성 판정")
|
|
for e in ENGINES:
|
|
src = os.path.join(OUT, f"qb_{e}_results.jsonl")
|
|
dst = os.path.join(OUT, f"qb_sentiment_{e}.jsonl")
|
|
if not os.path.exists(src): sys.exit(f"답변 파일 없음: {src}")
|
|
if a.rejudge and os.path.exists(dst): os.remove(dst)
|
|
sh([sys.executable, "scripts/sentiment_qb.py", "--in", src, "--out", dst,
|
|
"--clinic", a.clinic, "--alias", a.alias])
|
|
|
|
print("[3/3] 리포트")
|
|
outs = [os.path.join(OUT, f"qb_sentiment_{e}.jsonl") for e in ENGINES]
|
|
sh([sys.executable, "scripts/sentiment_qb.py", "--summary", *outs,
|
|
"--report", a.report, "--clinic", a.clinic])
|
|
|
|
rows = [json.loads(l) for p in outs for l in open(p, encoding="utf-8") if l.strip()]
|
|
W = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
|
|
strong = [r for r in rows if r["sentiment"] != "not_mentioned" and not r.get("weak_mention")]
|
|
s = sqb.score(rows)
|
|
sentiment = {
|
|
"rubric": sqb.RUBRIC_VERSION, "model": rows[0].get("model"),
|
|
"measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"answers": s["answers"], "mentioned": s["mentioned"], "score": s["score"],
|
|
"scoreStrongOnly": round(sum(W[r["sentiment"]] for r in strong) / len(strong), 1) if strong else None,
|
|
"weakMentions": sum(1 for r in rows if r.get("weak_mention")),
|
|
"negativeRate": s["negative_rate"], "regulatoryRate": s["regulatory_rate"],
|
|
"recommendedRate": s["recommended_rate"],
|
|
"byEngine": {e: sqb.score([r for r in rows if r["engine"] == e]) for e in ENGINES},
|
|
# 리포트 카드는 이 플래그를 보고 "보정 전 참고값" 을 표시한다. 2차 보정 전에는 확정이 아니다.
|
|
"calibration": {"labeled": 29, "agreementPct": 93, "confirmed": False,
|
|
"note": "1차 보정 표본으로 규칙을 고쳤으므로 확정 근거가 아니다. 새 30건 2차 보정 필요."},
|
|
}
|
|
p = os.path.join(OUT, "sentiment_summary.json")
|
|
open(p, "w", encoding="utf-8").write(json.dumps(sentiment, ensure_ascii=False, indent=2))
|
|
print(f" 요약 → {p}")
|
|
print(f" 언급 {s['mentioned']}/{s['answers']} · 감성 점수 {s['score']} · 약한 언급 제외 {sentiment['scoreStrongOnly']}")
|
|
|
|
extra = {}
|
|
nb_path = os.path.join(OUT, "qb_naver_briefing_results.jsonl")
|
|
if "naver_briefing" in ENGINES and os.path.exists(nb_path):
|
|
extra["naverBriefing"] = nb = naver_briefing_summary(nb_path)
|
|
open(os.path.join(OUT, "naver_briefing_summary.json"), "w", encoding="utf-8").write(json.dumps(nb, ensure_ascii=False, indent=2))
|
|
print(f" 네이버 AI 브리핑 노출 {nb['briefingShown']}/{nb['questions']} · 브리핑 내 뷰 언급 {nb['mentionedInBriefing']} · 논브랜드 {nb['nonBrandMentioned']}/{nb['nonBrandBriefingShown']}")
|
|
|
|
if a.build_id: patch_supabase(a.build_id, sentiment, extra)
|
|
else: print(" --build-id 없음. Supabase 반영은 건너뛴다.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|