o2o-infinith-demo/scripts/monthly_ai_measure.py
Haewon Kam 5faf867b05 feat(measure): 네이버 AI 브리핑을 질문 뱅크 실측 엔진으로 추가
네이버 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>
2026-09-15 16:55:59 +09:00

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()