o2o-infinith-demo/scripts/build_supporters_audit_v2_xlsx.py
Haewon Kam 76ec63bc1e feat(discovery): 기준표 v2.0 정식 채택 — 배지·헤더·랜딩 카피·비교 엑셀·덱 v2.0
- aeoGeoRubricV2 status active/2.0, 결과 rubricVersion 2.0. 리포트 헤더는 AEO·GEO 링 2개가 주, v1.0 종합은 보조 표기
- 랜딩: Hero 카피, ABMR 칩(AEO/GEO 목표), SampleReport(두 링 + 축별 8항목 바), Roadmap 제목, HowItWorks를 v2 계산값에서 파생. 발견 칩 수치 v1.1 스냅샷으로 정정
- 비교 엑셀 v2: scripts/build_supporters_v2_compare.ts(점수 계산 TS 단일 원본) → build_supporters_audit_v2_xlsx.py. 공식 AEO 42/GEO 56 · 서포터즈 noindex 상한 40(G1) · 색인 허용 AEO 94/GEO 77
- 덱 v2.0: build_viewclinic_deck_v2_0.py (17장, 표지 배지 v2.0, KPI AEO/GEO, 스코어카드 2장, Method v2.0) + 표준캔버스 + PDF. v1.0·v1.1 파일은 그대로
- docs/AEO_GEO_RUBRIC.md에 v2.0 채택 주석, AEO_GEO_RUBRIC_v2.md 재생성, 리포트 페이지 PDF 재출력

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:25:00 +09:00

90 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""공식 홈페이지 vs 서포터즈 사이트 · v2.0 AEO/GEO 비교 엑셀.
사용: python3 scripts/build_supporters_audit_v2_xlsx.py <v2_compare.json> <out.xlsx>
입력 JSON은 scripts/build_supporters_v2_compare.ts 가 만든다 (점수 계산은 TS 한 곳에서만)."""
import json, sys
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
src, out = sys.argv[1], sys.argv[2]
D = json.load(open(src, encoding="utf-8"))
V = D["variants"]; ITEMS = D["items"]; AX = {a["id"]: a for a in D["axes"]}
NAVY = "0A1128"; VIOLET = "4F1DA1"; LIGHT = "F4F6FB"; GOOD = "F3F0FF"; WARN = "FFF6ED"; CRIT = "FFF0F0"
hf = Font(name="Pretendard", bold=True, color="FFFFFF", size=14); bf = Font(name="Pretendard", size=14); bb = Font(name="Pretendard", size=14, bold=True)
tf = Font(name="Pretendard", size=18, bold=True, color=NAVY)
fill = lambda h: PatternFill("solid", fgColor=h); thin = Side(style="thin", color="E2E8F0"); bd = Border(left=thin, right=thin, top=thin, bottom=thin)
wrap = Alignment(wrap_text=True, vertical="top"); center = Alignment(horizontal="center", vertical="center", wrap_text=True)
def header(ws, row, cols):
for i, c in enumerate(cols, 1):
cell = ws.cell(row=row, column=i, value=c); cell.font = hf; cell.fill = fill(VIOLET); cell.alignment = center; cell.border = bd
def put(ws, row, col, val, bold=False, bg=None, align=wrap):
cell = ws.cell(row=row, column=col, value=val); cell.font = bb if bold else bf; cell.alignment = align; cell.border = bd
if bg: cell.fill = fill(bg)
return cell
def pct_bg(p):
if p is None: return LIGHT
return CRIT if p <= 40 else WARN if p <= 60 else GOOD
def widths(ws, ws_widths):
for i, w in enumerate(ws_widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
wb = Workbook()
# ── 1. 요약
ws = wb.active; ws.title = "요약"
ws["A1"] = "뷰성형외과 공식 홈페이지 vs 뷰 서포터즈 사이트 · AEO / GEO v2.0"; ws["A1"].font = tf
ws["A2"] = f"생성 {D['generatedAt']} · 기준표 v{D['rubricVersion']} · {D['note']}"; ws["A2"].font = bf; ws["A2"].alignment = wrap; ws.merge_cells("A2:E2"); ws.row_dimensions[2].height = 60
header(ws, 4, ["구분"] + [v["label"] for v in V] + ["비고"])
rows = [
("AEO 답변 준비도 (100)", [f"{v['aeo']['score']} / {v['aeo']['grade']}" + (f" (상한 {','.join(v['aeo']['capped'])}, 원점수 {v['aeo']['raw']})" if v['aeo']['capped'] else "") for v in V], AX["aeo"]["question"]),
("GEO 출처 준비도 (100)", [f"{v['geo']['score']} / {v['geo']['grade']}" + (f" (상한 {','.join(v['geo']['capped'])}, 원점수 {v['geo']['raw']})" if v['geo']['capped'] else "") for v in V], AX["geo"]["question"]),
("검증 항목 (AEO / GEO, 각 8)", [f"{v['aeo']['verified']} / {v['geo']['verified']}" for v in V], "미검증 항목은 분모 제외"),
("게이트", [" · ".join(f"{g['id']} {'통과' if g['passed'] else '실패(' + ','.join(g['failedBy']) + ')'}" for g in v["gates"]) for v in V], "실패 시 두 축 상한 40. 점수가 아니라 상한"),
("v1.0 종합 (참고)", [f"{v['v1']['score']} / {v['v1']['grade']}" for v in V], "36항목 종합. v2.0과 비교하지 않는다"),
]
for r, (k, vals, note) in enumerate(rows, 5):
put(ws, r, 1, k, bold=True, bg=LIGHT)
for i, val in enumerate(vals, 2): put(ws, r, i, val, bold=(r <= 6), align=center)
put(ws, r, len(vals) + 2, note)
r = 5 + len(rows) + 1
put(ws, r, 1, "게이트 정의", bold=True, bg=LIGHT); ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=5)
for g in D["gates"]:
r += 1; put(ws, r, 1, f"{g['id']} {g['name']}", bold=True); put(ws, r, 2, f"v1.0 {', '.join(g['criterionIds'])} 중 하나라도 level ≤ {g['failLevel']} → 상한 {g['cap']}"); ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3); put(ws, r, 4, g["reason"]); ws.merge_cells(start_row=r, start_column=4, end_row=r, end_column=5)
widths(ws, [30, 34, 34, 34, 50])
# ── 2. 항목별 비교
ws = wb.create_sheet("항목별 비교 (16항목)")
cols = ["ID", "", "항목", "배점", "채점", "v1.0 파생 신호(비중)"]
for v in V: cols += [f"{v['label']} %", "점수", "근거"]
header(ws, 1, cols)
for r, it in enumerate(ITEMS, 2):
put(ws, r, 1, it["id"], bold=True); put(ws, r, 2, AX[it["axis"]]["code"] + " " + AX[it["axis"]]["name"]); put(ws, r, 3, it["name"] + "\n" + it["question"]); put(ws, r, 4, it["weight"], align=center)
put(ws, r, 5, {"auto": "자동", "semi": "반자동", "manual": "사람"}[it["method"]], align=center); put(ws, r, 6, it["derivedFrom"] or "없음 (사람 판정)")
c = 7
for v in V:
row = next(x for x in v["items"] if x["id"] == it["id"])
put(ws, r, c, "미검증" if row["pct"] is None else row["pct"], bg=pct_bg(row["pct"]), align=center, bold=True)
put(ws, r, c + 1, f"{row['earned']} / {it['weight']}" if row["pct"] is not None else "", align=center)
put(ws, r, c + 2, ("[사람 판정] " if row["basis"] == "judged" else "") + (row["evidence"] or row["signals"]))
c += 3
ws.row_dimensions[r].height = 120
widths(ws, [8, 16, 34, 7, 8, 34] + [10, 10, 60] * len(V)); ws.freeze_panes = "D2"
# ── 3. 축별 해석
ws = wb.create_sheet("해석")
header(ws, 1, ["", "공식 홈페이지가 낮은 이유", "서포터즈가 높은 이유", "서포터즈 남은 과제"])
def low(v, axis, n=3):
xs = [x for x in v["items"] if x["id"].startswith(axis.upper()) and x["pct"] is not None]; xs.sort(key=lambda x: x["pct"]); return xs[:n]
def hi(v, axis, n=3):
xs = [x for x in v["items"] if x["id"].startswith(axis.upper()) and x["pct"] is not None]; xs.sort(key=lambda x: -x["pct"]); return xs[:n]
off, sup = V[0], V[-1]; name = {i["id"]: i["name"] for i in ITEMS}
for r, ax in enumerate(["aeo", "geo"], 2):
put(ws, r, 1, f"{AX[ax]['code']} {AX[ax]['name']}\n공식 {off[ax]['score']} → 서포터즈 {sup[ax]['score']}", bold=True, bg=LIGHT)
put(ws, r, 2, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in low(off, ax)))
put(ws, r, 3, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in hi(sup, ax)))
put(ws, r, 4, "\n".join(f"{x['id']} {name[x['id']]} {x['pct']}%" for x in low(sup, ax)))
ws.row_dimensions[r].height = 110
widths(ws, [26, 44, 44, 44])
wb.save(out); print("saved", out)