o2o-infinith-demo/scripts/export_qb_excel.py
Haewon Kam f8a254126c feat: 뷰성형외과 Question Bank 120문항 2엔진 실측 + 기준표 v1.1 과제 + 점수 정합성 교정(50/C)
- Question Bank 120문항(사전판정 ◯5/△35/✕80) + OpenAI·Perplexity API 실측: 브랜드 언급 97~100%, 논브랜드 GPT 11%/PPLX 3%, 시술 정보 52문항 0건
- 러너 run_question_bank_openai.py(재개 가능)·merge_qb_results.py·export_qb_excel.py, 결과 MD 3종 + xlsx 5시트 + raw JSONL
- AEO_GEO_RUBRIC.md v1.1 과제 5건: 서브도메인 실측, D3 표면 범위 확대, 엔진별 판정, 브랜드/논브랜드 축, 점수 표기 정합성
- 점수 교정: scoreDiscovery 계산값 50/C(44.1/89, 검증 32/36)로 진단 리포트 덱·POC 제안서 일괄 교정(구판 48, 커밋 메시지 49는 착오)
- 진단 리포트 덱 13→14장: Validation 슬라이드 추가, A5·B4·C6 재채점 반영, 연락처 o2oteam@o2o.kr 교정
- POC 제안서 10→14장: 부록 4장(QB 설계·실측 KPI·브랜드vs논브랜드·블로그 인용 증거), 랜딩 스크린샷 50점 화면 재캡처

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155BJMHJyhqqAPGYjGKYhPS
2026-09-01 11:03:15 +09:00

170 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Question Bank 120문항 + 2엔진 실측 결과를 엑셀(xlsx)로 내보낸다.
시트: 요약 / 통합결과 / 병원빈도 / GPT상세 / PPLX상세
"""
import json
import os
import time
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
QUESTIONS = os.path.join(ROOT, "scripts", "question_bank_viewclinic.json")
OUT = os.path.join(ROOT, "docs", "reports", "Viewclinic_QB_Results.xlsx")
INK = "0A1128"
VIOLET = "4F1DA1"
LIGHT = "F7F8FC"
GOOD = "DCFCE7"
BAD = "FEE2E2"
WARN = "FEF9C3"
CAT_LABEL = {
"A": "브랜드 직접", "B": "위치·운영", "C": "지역 추천·비교",
"D1": "가슴성형", "D2": "눈성형", "D3": "코성형", "D4": "안면윤곽·양악",
"D5": "지방성형", "D6": "피부·리프팅", "D7": "재수술",
"E": "신뢰·안전", "F": "가격·이벤트", "G": "후기·평판",
}
def load(engine):
p = os.path.join(ROOT, "scripts", "out", f"qb_{engine}_results.jsonl")
rows = {}
for line in open(p):
if line.strip():
r = json.loads(line)
rows[r["id"]] = r
return rows
def style_header(ws, ncols, row=1):
for c in range(1, ncols + 1):
cell = ws.cell(row=row, column=c)
cell.font = Font(bold=True, color="FFFFFF", size=11)
cell.fill = PatternFill("solid", fgColor=INK)
cell.alignment = Alignment(vertical="center", wrap_text=True)
ws.freeze_panes = ws.cell(row=row + 1, column=1)
def main():
qs = json.load(open(QUESTIONS))
oa, px = load("openai"), load("perplexity")
wb = Workbook()
# ── 요약 ─────────────────────────────────────────────
ws = wb.active
ws.title = "요약"
ws["A1"] = "뷰성형외과 AI Discovery Question Bank 실측 결과"
ws["A1"].font = Font(bold=True, size=16, color=INK)
ws["A2"] = f"측정일 {time.strftime('%Y-%m-%d')} · OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
ws["A3"] = "답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정. 문의 o2oteam@o2o.kr"
ws["A2"].font = ws["A3"].font = Font(size=10, color="666B7A")
def brand(q):
return "뷰성형외과" in q["question"]
ws.append([])
ws.append(["구분", "문항 수", "ChatGPT 뷰 언급", "ChatGPT 언급률", "Perplexity 뷰 언급", "Perplexity 언급률"])
hr = ws.max_row
for label, group in (("브랜드 질문 (질문에 병원명 포함)", [q for q in qs if brand(q)]),
("논브랜드 질문 (추천·비교·시술 정보)", [q for q in qs if not brand(q)]),
("전체", qs)):
om = sum(1 for q in group if oa[q["id"]].get("view_mentioned"))
pm = sum(1 for q in group if px[q["id"]].get("view_mentioned"))
ws.append([label, len(group), om, f"{om*100//len(group)}%", pm, f"{pm*100//len(group)}%"])
style_header(ws, 6, hr)
ws.append([])
ws.append(["사전판정 분포", "◯ 인용 기대 5", "△ 불안정 35", "✕ 인용 불가 80"])
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
for col, w in zip("ABCDEF", [34, 10, 16, 14, 16, 14]):
ws.column_dimensions[col].width = w
# ── 통합결과 ─────────────────────────────────────────
ws = wb.create_sheet("통합결과")
ws.append(["ID", "카테고리", "질문", "퍼널", "브랜드", "사전판정",
"GPT 언급", "GPT 순위", "GPT 상위 병원 (등장순)",
"PPLX 언급", "PPLX 순위", "PPLX 상위 병원 (등장순)"])
style_header(ws, 12)
for q in qs:
o, p = oa[q["id"]], px[q["id"]]
oc = ", ".join([c for c in o.get("clinics", []) if isinstance(c, str)][:5])
pc = ", ".join([c for c in p.get("clinics", []) if isinstance(c, str)][:5])
ws.append([q["id"], CAT_LABEL[q["id"].split("-")[0]], q["question"], q["stage"],
"O" if brand(q) else "", q["prior"],
"O" if o.get("view_mentioned") else "X", o.get("view_rank") or "",
oc or "(병원 언급 없음)",
"O" if p.get("view_mentioned") else "X", p.get("view_rank") or "",
pc or "(병원 언급 없음)"])
r = ws.max_row
for col, eng in ((7, o), (10, p)):
ws.cell(row=r, column=col).fill = PatternFill(
"solid", fgColor=GOOD if eng.get("view_mentioned") else BAD)
for c in range(1, 13):
ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=c in (3, 9, 12))
for col, w in zip(range(1, 13), [8, 13, 44, 7, 7, 9, 9, 9, 44, 9, 9, 44]):
ws.column_dimensions[get_column_letter(col)].width = w
# ── 병원빈도 (논브랜드 한정) ─────────────────────────
ws = wb.create_sheet("병원빈도")
ws.append(["엔진", "병원", "언급 질문 수", "1순위 등장 수"])
style_header(ws, 4)
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):
return n[: -len(suf)]
return n
for eng_label, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)):
freq, label = {}, {}
for q in qs:
if brand(q):
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
for key, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0]):
ws.append([eng_label, label[key], cnt, first])
if key == "뷰성형외과":
for c in range(1, 5):
ws.cell(row=ws.max_row, column=c).fill = PatternFill("solid", fgColor=WARN)
for col, w in zip("ABCD", [16, 30, 13, 13]):
ws.column_dimensions[col].width = w
# ── 엔진별 상세 (답변 전문 + 인용) ────────────────────
for sheet, data in (("GPT상세", oa), ("PPLX상세", px)):
ws = wb.create_sheet(sheet)
ws.append(["ID", "질문", "뷰 언급", "순위", "답변 전문", "인용 URL"])
style_header(ws, 6)
for q in qs:
r = data[q["id"]]
ws.append([q["id"], q["question"],
"O" if r.get("view_mentioned") else "X", r.get("view_rank") or "",
r.get("answer", "")[:32000], "\n".join(r.get("citations", [])[:10])])
row = ws.max_row
for c in range(1, 7):
ws.cell(row=row, column=c).alignment = Alignment(vertical="top", wrap_text=c in (2, 5, 6))
for col, w in zip("ABCDEF", [8, 36, 8, 6, 90, 50]):
ws.column_dimensions[col].width = w
wb.save(OUT)
print("저장:", OUT)
if __name__ == "__main__":
main()