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

356 lines
15 KiB
Python

#!/usr/bin/env python3
"""뷰성형외과 Question Bank 120문항 답변엔진 실측 러너 (OpenAI / Perplexity / 네이버 AI 브리핑).
측정 방식:
- openai: Responses API + web_search 도구 (서울 위치 고정)
- perplexity: chat/completions, model=sonar (자체 웹검색 내장)
- naver_briefing: SerpApi naver_ai_overview (제3자 SERP API). 네이버는 공식 API가 없다.
ChatGPT 소비자 UI 자동 조회는 약관 위반이므로 쓰지 않는다.
naver_briefing 은 haewon 결정(2026-09-15)으로 넣었다. search.naver.com robots.txt 가 모든 봇과
AI·RAG 목적 수집을 금지하고 있어, 결과를 고객 리포트에 쓰기 전에 법무 확인이 필요하다.
브리핑이 뜨지 않는 질문은 answer 를 비우고 briefing=false 로 기록한다(그것도 결과다).
SerpApi 는 같은 파라미터를 1시간 캐시한다. 반복 측정은 --no-cache 로 새로 조회한다(검색 1회씩 과금).
각 질문을 원문 그대로 1회 질의하고, 답변에서 언급된 병원명 상위 5개를
후처리 모델(OpenAI gpt-4o-mini, 없으면 Gemini 2.5 Flash)로 추출해 JSONL 기록.
재실행 시 완료된 문항은 건너뛴다.
사용:
python3 scripts/run_question_bank_openai.py --engine perplexity
OPENAI_API_KEY=... python3 scripts/run_question_bank_openai.py --engine openai
python3 scripts/run_question_bank_openai.py --engine openai --summarize
SERPAPI_API_KEY=... python3 scripts/run_question_bank_openai.py --engine naver_briefing --device mobile --limit 5
"""
import argparse
import json
import os
import re
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
try:
import certifi
SSL_CTX = ssl.create_default_context(cafile=certifi.where())
except ImportError:
SSL_CTX = ssl.create_default_context()
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
QUESTIONS = os.path.join(ROOT, "scripts", "question_bank_viewclinic.json")
OUT_DIR = os.path.join(ROOT, "scripts", "out")
EXTRACT_PROMPT = """아래는 AI 검색의 답변이다. 이 답변에서 언급된 성형외과·의원·클리닉 이름을 등장 순서대로 최대 5개 추출하라.
- 병원 이름만 (플랫폼·앱·언론사 제외. 예: 강남언니, 바비톡, 네이버는 병원이 아님)
- "뷰성형외과"는 VIEW성형외과, View Plastic Surgery 등 표기 변형도 동일 병원으로 본다
- JSON만 출력: {"clinics": ["병원1", ...], "view_mentioned": true/false, "view_rank": 순번 또는 null}
답변:
"""
def load_dotenv():
path = os.path.join(ROOT, ".env")
if not os.path.exists(path):
return
for line in open(path):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
def post(url, payload, headers, timeout=180):
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", **headers})
with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r:
return json.loads(r.read())
def get_retry(url, tries=4, timeout=120):
for i in range(tries):
try:
with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout, context=SSL_CTX) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
if e.code in (429, 500, 502, 503) and i < tries - 1:
time.sleep(15 * (i + 1))
continue
# 결과 없음도 4xx 본문으로 올 수 있어 JSON 이면 그대로 돌려준다
try:
return json.loads(body)
except json.JSONDecodeError:
raise RuntimeError(f"HTTP {e.code}: {body[:500]}") from e
except (urllib.error.URLError, TimeoutError):
if i < tries - 1:
time.sleep(10)
continue
raise
def post_retry(url, payload, headers, tries=4):
for i in range(tries):
try:
return post(url, payload, headers)
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
if e.code in (429, 500, 502, 503) and i < tries - 1:
wait = 15 * (i + 1)
print(f" HTTP {e.code}, {wait}s 대기 후 재시도", flush=True)
time.sleep(wait)
continue
raise RuntimeError(f"HTTP {e.code}: {body[:500]}") from e
except (urllib.error.URLError, TimeoutError):
if i < tries - 1:
time.sleep(10)
continue
raise
# ---------- engines ----------
def openai_answer_text(resp):
texts, urls = [], []
for item in resp.get("output", []):
if item.get("type") != "message":
continue
for c in item.get("content", []):
if c.get("type") == "output_text":
texts.append(c.get("text", ""))
for a in c.get("annotations", []):
if a.get("type") == "url_citation" and a.get("url"):
urls.append(a["url"])
return "\n".join(texts).strip(), urls
class OpenAIEngine:
name = "openai"
def __init__(self, model, search_tool):
self.key = os.environ.get("OPENAI_API_KEY")
if not self.key:
sys.exit("OPENAI_API_KEY가 필요합니다 (.env 또는 환경변수)")
self.model = model
self.search_tool = search_tool
def ask(self, question):
tool = {"type": self.search_tool,
"user_location": {"type": "approximate", "country": "KR",
"city": "Seoul", "timezone": "Asia/Seoul"}}
try:
resp = post_retry("https://api.openai.com/v1/responses",
{"model": self.model, "tools": [tool], "input": question},
{"Authorization": f"Bearer {self.key}"})
except RuntimeError as e:
if self.search_tool == "web_search" and "web_search" in str(e):
print(" web_search 거부됨, web_search_preview로 폴백", flush=True)
self.search_tool = "web_search_preview"
return self.ask(question)
raise
return openai_answer_text(resp)
class PerplexityEngine:
name = "perplexity"
def __init__(self, model):
self.key = os.environ.get("PERPLEXITY_API_KEY")
if not self.key:
sys.exit("PERPLEXITY_API_KEY가 필요합니다 (.env)")
self.model = model
def ask(self, question):
resp = post_retry("https://api.perplexity.ai/chat/completions",
{"model": self.model,
"messages": [{"role": "user", "content": question}]},
{"Authorization": f"Bearer {self.key}"})
text = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
urls = list(resp.get("citations") or [])
for s in resp.get("search_results") or []:
if s.get("url"):
urls.append(s["url"])
return text, urls
class NaverBriefingEngine:
"""SerpApi naver_ai_overview. 반환 (answer, urls). 브리핑이 없으면 ("", []) 와 self.last_meta.briefing=False."""
name = "naver_briefing"
def __init__(self, device, no_cache):
self.key = os.environ.get("SERPAPI_API_KEY")
if not self.key:
sys.exit("SERPAPI_API_KEY가 필요합니다 (.env). serpapi.com 가입 후 대시보드의 API Key")
self.device = device
self.no_cache = no_cache
self.model = f"serpapi:naver_ai_overview:{device}"
self.last_meta = {}
def ask(self, question):
params = {"engine": "naver_ai_overview", "query": question, "device": self.device, "api_key": self.key}
if self.no_cache:
params["no_cache"] = "true"
resp = get_retry("https://serpapi.com/search.json?" + urllib.parse.urlencode(params))
ov = resp.get("ai_overview") if isinstance(resp.get("ai_overview"), dict) else resp
text = (ov.get("markdown") or "").strip()
if not text:
text = "\n".join(b.get("snippet", "") for b in ov.get("text_blocks") or [] if isinstance(b, dict)).strip()
refs = [r for r in ov.get("references") or [] if isinstance(r, dict)]
urls = [r["link"] for r in refs if r.get("link")]
self.last_meta = {
"briefing": bool(text),
"device": self.device,
"sources": [{"title": r.get("title"), "source": r.get("source"), "link": r.get("link")} for r in refs][:10],
"serpapi_id": (resp.get("search_metadata") or {}).get("id"),
}
if not text and resp.get("error"):
self.last_meta["no_briefing_reason"] = str(resp["error"])[:200]
return text, urls
# ---------- extraction ----------
def extract_clinics(answer):
prompt = EXTRACT_PROMPT + answer[:8000]
text = None
okey = os.environ.get("OPENAI_API_KEY")
if okey:
resp = post_retry("https://api.openai.com/v1/responses",
{"model": "gpt-4o-mini", "input": prompt},
{"Authorization": f"Bearer {okey}"})
text, _ = openai_answer_text(resp)
else:
gkey = os.environ.get("GEMINI_API_KEY")
if not gkey:
sys.exit("추출용으로 OPENAI_API_KEY 또는 GEMINI_API_KEY가 필요합니다")
resp = post_retry(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + gkey,
{"contents": [{"parts": [{"text": prompt}]}]}, {})
parts = resp.get("candidates", [{}])[0].get("content", {}).get("parts", [])
text = "\n".join(p.get("text", "") for p in parts)
m = re.search(r"\{.*\}", text or "", re.S)
if not m:
return {"clinics": [], "view_mentioned": False, "view_rank": None,
"parse_error": (text or "")[:200]}
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return {"clinics": [], "view_mentioned": False, "view_rank": None,
"parse_error": text[:200]}
# ---------- io / summary ----------
def out_path(engine):
return os.path.join(OUT_DIR, f"qb_{engine}_results.jsonl")
def load_done(engine):
done = {}
p = out_path(engine)
if os.path.exists(p):
for line in open(p):
if line.strip():
row = json.loads(line)
done[row["id"]] = row
return done
def summarize(engine):
done = load_done(engine)
if not done:
print(f"{engine}: 결과 없음")
return
label = {"openai": "OpenAI 웹검색(Responses API + web_search, 서울 고정)",
"perplexity": "Perplexity API (sonar)",
"naver_briefing": "네이버 AI 브리핑 (SerpApi naver_ai_overview, 제3자 SERP API)"}[engine]
qs = json.load(open(QUESTIONS))
freq, mentioned = {}, 0
lines = [f"# 뷰성형외과 Question Bank 실측 결과: {engine}", "",
f"측정일: {time.strftime('%Y-%m-%d')} / 방식: {label} / {len(done)}문항 완료", "",
"| # | 질문 | 사전판정 | 뷰 언급 | 뷰 순위 | 답변 내 상위 병원 (등장순) |",
"|---|---|---|---|---|---|"]
for q in qs:
r = done.get(q["id"])
if not r:
continue
clinics = [c for c in r.get("clinics", []) if isinstance(c, str)][:5]
for i, c in enumerate(clinics):
freq.setdefault(c, [0, 0])
freq[c][0] += 1
if i == 0:
freq[c][1] += 1
vm = r.get("view_mentioned")
if vm:
mentioned += 1
rank = r.get("view_rank")
lines.append(f"| {q['id']} | {q['question']} | {q['prior']} | {'O' if vm else 'X'} | "
f"{rank if rank else '-'} | {', '.join(clinics) if clinics else '(병원 언급 없음)'} |")
lines += ["", "## 집계", "",
f"- 뷰성형외과 언급: {mentioned}/{len(done)}문항 ({mentioned*100//max(len(done),1)}%)",
*([f"- AI 브리핑 노출: {sum(1 for r in done.values() if r.get('briefing'))}/{len(done)}문항 "
"(브리핑이 없으면 언급 없음으로 센다)"] if engine == "naver_briefing" else []),
"", "### 병원별 언급 빈도 (상위 15)", "",
"| 병원 | 언급 질문 수 | 1순위 등장 수 |", "|---|---|---|"]
for name, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0])[:15]:
lines.append(f"| {name} | {cnt} | {first} |")
out_md = os.path.join(ROOT, "docs", "reports", "viewclinic", "03_question_bank", f"Viewclinic_QB_{engine}_results.md")
open(out_md, "w").write("\n".join(lines) + "\n")
print(f"저장: {out_md}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--engine", choices=["openai", "perplexity", "naver_briefing"], default="openai")
ap.add_argument("--device", choices=["mobile", "desktop"], default="mobile", help="naver_briefing 전용")
ap.add_argument("--no-cache", action="store_true", help="naver_briefing 반복 측정 시 SerpApi 캐시 무시")
ap.add_argument("--model", default=None)
ap.add_argument("--search-tool", default="web_search")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--sleep", type=float, default=1.5)
ap.add_argument("--summarize", action="store_true")
args = ap.parse_args()
load_dotenv()
if args.summarize:
summarize(args.engine)
return
if args.engine == "openai":
eng = OpenAIEngine(args.model or "gpt-4o", args.search_tool)
elif args.engine == "naver_briefing":
eng = NaverBriefingEngine(args.device, args.no_cache)
else:
eng = PerplexityEngine(args.model or "sonar")
os.makedirs(OUT_DIR, exist_ok=True)
qs = json.load(open(QUESTIONS))
done = load_done(args.engine)
todo = [q for q in qs if q["id"] not in done]
if args.limit:
todo = todo[: args.limit]
print(f"[{args.engine}] 전체 {len(qs)} / 완료 {len(done)} / 이번 실행 {len(todo)}")
with open(out_path(args.engine), "a") as f:
for n, q in enumerate(todo, 1):
print(f"[{n}/{len(todo)}] {q['id']} {q['question'][:30]}", flush=True)
answer, urls = eng.ask(q["question"])
ext = extract_clinics(answer) if answer else {
"clinics": [], "view_mentioned": False, "view_rank": None}
row = {"id": q["id"], "question": q["question"], "prior": q["prior"],
"engine": args.engine, "model": eng.model,
"answer": answer, "citations": urls[:10], **ext,
**getattr(eng, "last_meta", {}),
"ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
f.write(json.dumps(row, ensure_ascii=False) + "\n")
f.flush()
time.sleep(args.sleep)
summarize(args.engine)
if __name__ == "__main__":
main()