o2o-infinith-demo/scripts/merge_qb_results.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

145 lines
6.8 KiB
Python

#!/usr/bin/env python3
"""OpenAI·Perplexity·네이버 AI 브리핑 실측 JSONL을 합쳐 질문별 통합 비교표 MD를 생성한다.
네이버 결과 파일이 없으면 두 엔진만으로 만든다."""
import json
import os
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
QUESTIONS = os.path.join(ROOT, "scripts", "question_bank_viewclinic.json")
OUT_MD = os.path.join(ROOT, "docs", "reports", "viewclinic", "03_question_bank", "Viewclinic_QB_Results_Combined.md")
CAT_LABEL = {
"A": "A. 브랜드 직접", "B": "B. 위치·운영", "C": "C. 지역 추천·비교",
"D1": "D1. 가슴성형", "D2": "D2. 눈성형", "D3": "D3. 코성형",
"D4": "D4. 안면윤곽·양악", "D5": "D5. 지방성형", "D6": "D6. 피부·리프팅",
"D7": "D7. 재수술", "E": "E. 신뢰·안전", "F": "F. 가격·이벤트", "G": "G. 후기·평판",
}
# 질문 문장에 병원명이 들어간 브랜드 질문 (뷰 언급이 당연한 그룹)
def is_brand(q):
return "뷰성형외과" in q["question"]
def canon(name):
"""빈도 집계용 병원명 정규화: 표기 변형을 하나로 합친다."""
n = name.strip().lower().replace(" ", "")
if "view" in n and ("plastic" in n or "성형" in n) or n.startswith("뷰성형"):
return "뷰성형외과"
for suf in ("성형외과의원", "성형외과", "의원", "클리닉", "clinic", "plasticsurgery"):
if n.endswith(suf) and len(n) > len(suf):
n = n[: -len(suf)]
break
return n
def load(engine):
p = os.path.join(ROOT, "scripts", "out", f"qb_{engine}_results.jsonl")
rows = {}
if os.path.exists(p):
for line in open(p):
if line.strip():
r = json.loads(line)
rows[r["id"]] = r
return rows
def measured_on(data):
"""엔진 결과의 실제 측정일(ts 최소~최대). 생성일로 찍으면 과거 측정이 오늘 측정처럼 보인다."""
days = sorted({(r.get("ts") or r.get("measured_at") or "")[:10] for r in data.values()} - {""})
if not days:
return "미상"
return days[0] if days[0] == days[-1] else f"{days[0]}~{days[-1]}"
def cell(r):
if not r:
return "미실측", "-"
if r.get("engine") == "naver_briefing" and not r.get("briefing"):
return "브리핑 없음", "-"
clinics = [c for c in r.get("clinics", []) if isinstance(c, str)][:5]
rank = r.get("view_rank")
mark = f"O ({rank}위)" if r.get("view_mentioned") else "X"
return mark, ", ".join(clinics) if clinics else "(병원 언급 없음)"
def main():
qs = json.load(open(QUESTIONS))
oa, px, nv = load("openai"), load("perplexity"), load("naver_briefing")
engines = [("ChatGPT(OpenAI)", oa), ("Perplexity", px)] + ([("네이버 AI 브리핑", nv)] if nv else [])
lines = ["# 뷰성형외과 Question Bank 실측 결과: " + " x ".join(e for e, _ in engines) + " 통합", "",
"측정일: " + " · ".join(f"{e} {measured_on(d)}" for e, d in engines),
"측정 방식: OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
+ (" / 네이버 AI 브리핑(SerpApi naver_ai_overview, 제3자 SERP API, 모바일)" if nv else "") + ". "
"답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정했다."
+ (" 네이버는 공식 API가 없어 제3자 API를 썼으며, 고객 리포트 사용 전 법무 확인이 필요하다." if nv else ""), ""]
# 집계
stats = {}
for eng, data in engines:
b = [q for q in qs if is_brand(q) and q["id"] in data]
nb = [q for q in qs if not is_brand(q) and q["id"] in data]
bm = sum(1 for q in b if data[q["id"]].get("view_mentioned"))
nbm = sum(1 for q in nb if data[q["id"]].get("view_mentioned"))
stats[eng] = (len(b), bm, len(nb), nbm)
lines += ["## 요약", "",
"| 엔진 | 브랜드 질문 뷰 언급 | 논브랜드 질문 뷰 언급 |", "|---|---|---|"]
for eng, (b, bm, nb, nbm) in stats.items():
lines.append(f"| {eng} | {bm}/{b} ({bm*100//max(b,1)}%) | {nbm}/{nb} ({nbm*100//max(nb,1)}%) |")
if nv:
shown = [r for r in nv.values() if r.get("briefing")]
lines += ["", f"네이버 AI 브리핑 노출: {len(shown)}/{len(nv)}문항. 브리핑이 뜨지 않은 문항은 언급 없음으로 셌다."]
lines += ["",
"브랜드 질문은 질문 문장에 '뷰성형외과'가 들어간 문항이다. "
"승부처인 논브랜드 질문(추천·비교·시술 정보)에서의 언급률이 AI Discovery의 실제 성적이다.", ""]
# 병원별 빈도 (논브랜드 질문 한정)
for eng, data in engines:
freq, label = {}, {}
for q in qs:
if is_brand(q) or q["id"] not in data:
continue
seen = set()
for i, c in enumerate([c for c in data[q["id"]].get("clinics", []) if isinstance(c, str)][:5]):
key = canon(c)
if key in seen:
continue
seen.add(key)
label.setdefault(key, c)
freq.setdefault(key, [0, 0])
freq[key][0] += 1
if i == 0:
freq[key][1] += 1
freq = {label[k]: v for k, v in freq.items()}
lines += [f"### {eng}: 논브랜드 질문에서 언급된 병원 상위 15", "",
"| 병원 | 언급 질문 수 | 1순위 등장 수 |", "|---|---|---|"]
for name, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0])[:15]:
lines.append(f"| {name} | {cnt} | {first} |")
lines.append("")
# 문항별 표 (카테고리 섹션별)
lines += ["## 문항별 결과", "",
"GPT/PPLX" + ("/NAVER" if nv else "") + " 컬럼: O(순위) = 뷰성형외과 언급과 답변 내 등장 순번, X = 미언급"
+ (", 브리핑 없음 = 네이버가 AI 브리핑을 띄우지 않음" if nv else "") + ".", ""]
cur = None
for q in qs:
cat = q["id"].split("-")[0]
if cat != cur:
cur = cat
head = "| # | 질문 | 사전판정 | GPT | GPT 상위 병원 | PPLX | PPLX 상위 병원 |" + (" NAVER | NAVER 상위 병원 |" if nv else "")
lines += [f"### {CAT_LABEL.get(cat, cat)}", "", head, "|" + "---|" * (head.count("|") - 1)]
om, oc = cell(oa.get(q["id"]))
pm, pc = cell(px.get(q["id"]))
row = f"| {q['id']} | {q['question']} | {q['prior']} | {om} | {oc} | {pm} | {pc} |"
if nv:
nm, nc = cell(nv.get(q["id"]))
row += f" {nm} | {nc} |"
lines.append(row)
open(OUT_MD, "w").write("\n".join(lines) + "\n")
print(f"저장: {OUT_MD} (openai {len(oa)} / perplexity {len(px)} / naver_briefing {len(nv)}문항)")
if __name__ == "__main__":
main()