#!/usr/bin/env python3 """Question Bank 120문항 + 2엔진 실측 결과를 엑셀(xlsx)로 내보낸다. 시트: 요약 / 통합결과 / 병원빈도 / TOP50 / 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", "03_question_bank", "Viewclinic_QB_Results_TOP50.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 # ── TOP20 (논브랜드 84문항 · 2엔진 합산 랭킹) ───────── ws = wb.create_sheet("TOP50") ws["A1"] = "성형외과 AI Discovery TOP 50 (논브랜드 84문항 · ChatGPT+Perplexity 합산)" ws["A1"].font = Font(bold=True, size=14, color=INK) ws["A2"] = "점수 = 답변 내 등장 순위 가중치(1위 5점 ~ 5위 1점)를 질문·엔진 단위로 합산. 브랜드 직접 질문 36문항은 제외." ws["A2"].font = Font(size=10, color="666B7A") ws.append([]) ws.append(["순위", "병원", "AI Discovery 점수", "언급 질문 수(합산)", "GPT 언급", "PPLX 언급", "1순위 등장", "주요 등장 카테고리"]) hr = ws.max_row style_header(ws, 8, hr) agg = {} for eng_key, data in (("gpt", oa), ("pplx", px)): 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) a = agg.setdefault(key, {"label": "뷰성형외과" if key == "뷰성형외과" else c, "score": 0, "gpt": 0, "pplx": 0, "first": 0, "cats": {}}) a["score"] += 5 - i a[eng_key] += 1 if i == 0: a["first"] += 1 cat = CAT_LABEL[q["id"].split("-")[0]] a["cats"][cat] = a["cats"].get(cat, 0) + 1 ranked = sorted(agg.values(), key=lambda a: (-a["score"], -a["first"], -(a["gpt"] + a["pplx"]))) view_rank = next((i + 1 for i, a in enumerate(ranked) if canon(a["label"]) == "뷰성형외과"), None) shown = ranked[:50] if view_rank and view_rank > 50: shown = ranked[:50] + [ranked[view_rank - 1]] for idx, a in enumerate(shown): rank = idx + 1 if idx < 50 else view_rank cats = " · ".join(k for k, _ in sorted(a["cats"].items(), key=lambda x: -x[1])[:3]) ws.append([rank, a["label"], a["score"], a["gpt"] + a["pplx"], a["gpt"], a["pplx"], a["first"], cats]) if canon(a["label"]) == "뷰성형외과": for c in range(1, 9): ws.cell(row=ws.max_row, column=c).fill = PatternFill("solid", fgColor=WARN) ws.cell(row=ws.max_row, column=c).font = Font(bold=True) for col, w in zip("ABCDEFGH", [6, 30, 16, 16, 10, 10, 10, 40]): ws.column_dimensions[col].width = w # ── 엔진별 상세 (답변 전문 + 인용) ──────────────────── # 논브랜드 84문항만 수록 (브랜드 직접 질문 36개의 원본 답변은 scripts/out/ JSONL 참조) for sheet, data in (("GPT상세", oa), ("PPLX상세", px)): ws = wb.create_sheet(sheet) ws.append(["ID", "질문 (논브랜드 84문항)", "뷰 언급", "순위", "답변 전문", "인용 URL"]) style_header(ws, 6) for q in qs: if brand(q): continue 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()