feat(discovery): AEO/GEO 분리 채점 기준표 v2.0 초안 + 리포트 패널
- src/data/aeoGeoRubricV2.ts: AEO 답변 준비도 8항목·GEO 출처 준비도 8항목(각 100), 게이트 2종(크롤러 접근·본문 렌더링 → 상한 40), 부가 지표 11개 - v1.0 36항목을 v2 항목에 1:1 매핑(중복 0·미매핑 0, 스크립트 검사). 항목 점수 = v1 실측 레벨 가중 평균, 사람 판정(v2Results) 우선, 미검증은 분모 제외 - src/lib/discoveryScoreV2.ts 집계, src/components/discovery/AeoGeoV2Panel.tsx 리포트 패널(헤더 아래) - 뷰성형외과 재채점: v1 54/C → AEO 42/C · GEO 56/C (사람 판정 4개는 수집기 원문 근거 초안). 태하: 게이트 G1(A2) 실패로 40 상한 - docs/AEO_GEO_RUBRIC_v2.md는 scripts/gen_rubric_v2_doc.ts로 코드에서 생성 - tsconfig exclude에 supporters·templates·evidence 추가 (Astro 하위 프로젝트가 루트 tsc에 잡히던 문제) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
e52331a239
commit
c02de9ea51
143
docs/AEO_GEO_RUBRIC_v2.md
Normal file
143
docs/AEO_GEO_RUBRIC_v2.md
Normal file
@ -0,0 +1,143 @@
|
||||
# INFINITH AI Discovery 채점 기준표 v2.0 (초안) · AEO / GEO 분리 지표
|
||||
|
||||
> 코드 원본: `src/data/aeoGeoRubricV2.ts` · 집계: `src/lib/discoveryScoreV2.ts` · 타입: `src/types/discovery.ts` (v2 블록)
|
||||
> 상태: **draft** (2026-09-07). v1.0(36항목 종합 100점)은 유지하며 두 점수를 나란히 표시한다. 이 문서는 코드에서 생성한다(`npx tsx scripts/gen_rubric_v2_doc.ts`).
|
||||
|
||||
## 설계
|
||||
|
||||
- AEO 답변 준비도(100): 고객의 질문에 얼마나 명확하고 충분하게 답하는가.
|
||||
- GEO 출처 준비도(100): 그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가.
|
||||
- 같은 평가 항목을 두 점수에 중복 배정하지 않는다. v1.0 실측 항목 36개도 한 곳에만 매핑한다(아래 매핑표, 중복 0·미매핑 0을 스크립트로 검사).
|
||||
- 이 배점은 INFINITH 제품용 진단 기준이며, 검색엔진의 공식 점수나 인용 확률이 아니다.
|
||||
- 항목 점수 = 파생 신호(v1.0 실측 레벨 0~3)의 가중 평균. 사람 판정(`v2Results`)이 있으면 우선. 미검증은 분모 제외.
|
||||
- 게이트는 점수가 아니라 상한이다. 크롤러 차단·본문 미렌더면 두 축 모두 40 이하로 표시한다.
|
||||
- v1.0의 측정 인프라(E)·기술 위생 일부(F)·속도·hreflang·오픈웹 언급은 점수 밖 부가 지표로 남긴다.
|
||||
- 등급 경계는 v1.0과 같다: A(81+) · B(61–80) · C(41–60) · D(≤40).
|
||||
|
||||
## AEO · 답변 준비도 (Answer Readiness) · 100
|
||||
|
||||
고객의 질문에 얼마나 명확하고 충분하게 답하는가?
|
||||
|
||||
| ID | 항목 | 배점 | 채점 | v1.0 파생 신호(비중) | 확인 방법 | 3점 기준 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AEO1 | 질문 수요 충족 | 20 | 반자동 | C2 FAQ 섹션(1) | 질문 뱅크(업종별 7카테고리) 대비 답이 있는 문항 비율. 자동 신호는 FAQ 섹션·FAQPage 스키마(C2). 사람 판정은 QB 실측 결과로 덮어쓴다 | 카테고리 전반에 답, 문항 60% 이상 |
|
||||
| AEO2 | 답변의 직접성 | 20 | 반자동 | C1 정의문형 첫 문단(1) | 핵심 페이지 첫 200자에 [상호+지역+전문분야] 정의문 또는 질문에 대한 직접 답 존재(C1) | 전 핵심 페이지가 답으로 시작 |
|
||||
| AEO3 | 답변 단위의 자기완결성 | 15 | 사람 | C8 패시지 자기완결성(1) | 핵심 페이지 단락 샘플 10개 중 상호·시술명이 포함된 비율(C8) | 80% 이상 |
|
||||
| AEO4 | 의사결정에 필요한 정보 | 15 | 반자동 | C4 구체 수치 (가격·시간·회복)(0.6), C5 표·비교 구조(0.4) | 시술 페이지의 수치 밀도(C4)와 HTML 표·비교 구조(C5) | 시술별 수치 + 비교표 |
|
||||
| AEO5 | 정보 구조와 가독성 | 10 | 자동 | C3 제목 계층 의미성(1) | H1 1개·서술형, H2 논리 계층, 중복 H2 비율(C3) | H1 1개 서술형, H2 논리 계층 |
|
||||
| AEO6 | 다음 행동의 명확성 | 10 | 사람 | 없음 (사람 판정) | 핵심 페이지에서 예약 경로·전화·위치가 본문 텍스트로 연결되는지. 팝업·이미지 버튼만 있으면 감점. v1.0에 대응 항목이 없어 사람 판정 | 전화·예약·방문 3경로가 텍스트로 |
|
||||
| AEO7 | 핵심 답변의 텍스트 추출 | 5 | 자동 | A3 서버 렌더링 본문(0.6), F1 이미지 대체텍스트(0.4) | JS 미실행 HTML 본문 비율(A3) + 이미지 alt 누락률(F1) | 80% 이상 + alt 정비 |
|
||||
| AEO8 | 사이트 내 답변 발견 | 5 | 자동 | A5 sitemap.xml 제공(0.6), B6 canonical·중복 제거(0.4) | sitemap 선언·lastmod(A5) + canonical 정합(B6) | + lastmod 최신·파라미터 정리 |
|
||||
|
||||
## GEO · 출처 준비도 (Source Readiness) · 100
|
||||
|
||||
그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가?
|
||||
|
||||
| ID | 항목 | 배점 | 채점 | v1.0 파생 신호(비중) | 확인 방법 | 3점 기준 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| GEO1 | 주장과 근거의 일치 | 20 | 사람 | 없음 (사람 판정) | 핵심 페이지의 주장 문장 샘플 10개 중 출처·조건·시점이 붙은 비율. "1위·최다·안전" 류 주장에 기준 시점·집계 주체가 있는지. v1.0에 대응 항목이 없어 사람 판정 | 전부 원문 링크·시점·조건 명시 |
|
||||
| GEO2 | 출처의 적합성과 추적성 | 15 | 자동 | A4 HTTPS·리다이렉트 정합(1) | http/www 변형의 301 정본 수렴(A4). 사람 판정으로 페이지 URL 안정성·작성자·발행일 표기 추가 | 전부 301 정본 수렴 |
|
||||
| GEO3 | 전문성과 책임 주체 | 15 | 반자동 | C7 전문가 E-E-A-T (의료진 / 변호사)(0.6), B3 전문가 스키마 (Physician / Attorney)(0.4) | 전문가 자격·경력 텍스트(C7) + Physician/Attorney 스키마(B3) | + 스키마·sameAs·검토 표기 |
|
||||
| GEO4 | 병원 엔티티와 사실의 정합성 | 15 | 반자동 | B2 업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness)(0.2), B4 NAP 사이트 내 일관성(0.15), B5 sameAs 연결(0.1), D1 업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡)(0.2), D2 Google 비즈니스 프로필(0.1), D4 표면 간 NAP 완전 일치(0.15), D5 네이버 플레이스 (AI 브리핑 전용)(0.05), D6 위키데이터/위키백과 엔티티(0.05) | 업종 스키마(B2)·사이트 내 NAP(B4)·sameAs(B5)·버티컬 프로필(D1)·GBP(D2)·표면 간 NAP(D4)·플레이스(D5)·Wikidata(D6) | 전 표면 NAP 완전 일치 + 스키마 연결 |
|
||||
| GEO5 | 사실의 최신성과 정정 | 10 | 자동 | C6 신선도 신호(1) | dateModified·sitemap lastmod·본문 갱신일(C6). 사람 판정으로 확인일 병기·정정 기록 페이지 유무 추가 | 3개월 이내 + 확인일·정정 기록 |
|
||||
| GEO6 | 원천 대비 추가 가치 | 10 | 사람 | 없음 (사람 판정) | 홈페이지·타 사이트 문장과의 40자 연속 일치율, 원천 콘텐츠 5유형(체크리스트·조건 표·협진 동선·발언 인용·실측) 유무. 공식 홈페이지 자체를 진단할 때는 "제조사·학회 자료를 옮기지 않고 병원 고유 정보를 썼는가"로 본다 | 체크리스트·표·실측 등 원천 유형 3개 이상 |
|
||||
| GEO7 | 발행 관계와 투명성 | 10 | 사람 | 없음 (사람 판정) | 운영 주체·연락처·사업자 정보, 경제적 이해관계 고지(첫 부분), 후기·이벤트가 정보와 구분되는지. 공정위 추천·보증 심사지침 기준 | 고지가 첫 부분에, 정보·광고 구분 명확 |
|
||||
| GEO8 | 인용 식별자와 메타데이터 정합성 | 5 | 자동 | B1 JSON-LD 문법 유효성(1) | ld+json 전 블록 파싱 성공(B1). 사람 판정으로 headline·dateModified·author가 화면과 일치하는지 추가 | 전부 유효 + 화면과 일치 |
|
||||
|
||||
## 게이트 (상한)
|
||||
|
||||
| ID | 이름 | v1.0 항목 | 실패 조건 | 상한 | 적용 축 | 이유 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| G1 | AI 크롤러 접근 | A1, A2 | level ≤ 1 | 40 | AEO·GEO | 검색용 AI 봇이 차단되거나 CDN이 챌린지를 걸면 답도 출처도 될 수 없다. 점수 대신 상한 40으로 표시한다. |
|
||||
| G2 | 본문 렌더링 | A3 | level ≤ 0 | 40 | AEO·GEO | JS 미실행 HTML에 본문이 없으면 크롤러에게 빈 페이지다. |
|
||||
|
||||
## 점수 밖 부가 지표
|
||||
|
||||
| v1.0 항목 | 그룹 |
|
||||
|---|---|
|
||||
| A6 Bing 색인 (ChatGPT 검색 경로) | 색인·발견 |
|
||||
| A7 응답 속도·크롤 예산 | 속도·크롤 예산 |
|
||||
| B7 다국어 hreflang | 다국어 |
|
||||
| D3 오픈웹 언급 (티스토리·유튜브·언론) | 오픈웹 언급 |
|
||||
| E1 AI 크롤러 로그 (IP 검증) | 측정 인프라 |
|
||||
| E2 GA4 AI 유입 채널 | 측정 인프라 |
|
||||
| E3 GSC AI Overviews 노출 | 측정 인프라 |
|
||||
| E4 기준선 스팟체크 | 측정 인프라 |
|
||||
| F2 CMS 보안·최신성 | 기술 위생 |
|
||||
| F3 페이지 무게 | 기술 위생 |
|
||||
| F4 모바일 대응 | 기술 위생 |
|
||||
|
||||
## v1.0 → v2.0 매핑표 (36항목 전수)
|
||||
|
||||
| v1.0 | 항목 | 배점 | v2.0 위치 |
|
||||
|---|---|---|---|
|
||||
| A1 | 검색용 AI 봇 허용 | 5 | G1 |
|
||||
| A2 | CDN/WAF AI 차단 없음 | 3 | G1 |
|
||||
| A3 | 서버 렌더링 본문 | 3 | AEO7, G2 |
|
||||
| A4 | HTTPS·리다이렉트 정합 | 2 | GEO2 |
|
||||
| A5 | sitemap.xml 제공 | 3 | AEO8 |
|
||||
| A6 | Bing 색인 (ChatGPT 검색 경로) | 3 | aux:색인·발견 |
|
||||
| A7 | 응답 속도·크롤 예산 | 1 | aux:속도·크롤 예산 |
|
||||
| B1 | JSON-LD 문법 유효성 | 3 | GEO8 |
|
||||
| B2 | 업종 엔티티 스키마 (MedicalClinic / LegalService / LocalBusiness) | 5 | GEO4 |
|
||||
| B3 | 전문가 스키마 (Physician / Attorney) | 3 | GEO3 |
|
||||
| B4 | NAP 사이트 내 일관성 | 3 | GEO4 |
|
||||
| B5 | sameAs 연결 | 2 | GEO4 |
|
||||
| B6 | canonical·중복 제거 | 2 | AEO8 |
|
||||
| B7 | 다국어 hreflang | 2 | aux:다국어 |
|
||||
| C1 | 정의문형 첫 문단 | 4 | AEO2 |
|
||||
| C2 | FAQ 섹션 | 4 | AEO1 |
|
||||
| C3 | 제목 계층 의미성 | 3 | AEO5 |
|
||||
| C4 | 구체 수치 (가격·시간·회복) | 4 | AEO4 |
|
||||
| C5 | 표·비교 구조 | 2 | AEO4 |
|
||||
| C6 | 신선도 신호 | 3 | GEO5 |
|
||||
| C7 | 전문가 E-E-A-T (의료진 / 변호사) | 3 | GEO3 |
|
||||
| C8 | 패시지 자기완결성 | 2 | AEO3 |
|
||||
| D1 | 업종 버티컬 프로필 (병의원: 강남언니 / 법률: 로톡) | 6 | GEO4 |
|
||||
| D2 | Google 비즈니스 프로필 | 4 | GEO4 |
|
||||
| D3 | 오픈웹 언급 (티스토리·유튜브·언론) | 4 | aux:오픈웹 언급 |
|
||||
| D4 | 표면 간 NAP 완전 일치 | 3 | GEO4 |
|
||||
| D5 | 네이버 플레이스 (AI 브리핑 전용) | 2 | GEO4 |
|
||||
| D6 | 위키데이터/위키백과 엔티티 | 1 | GEO4 |
|
||||
| E1 | AI 크롤러 로그 (IP 검증) | 3 | aux:측정 인프라 |
|
||||
| E2 | GA4 AI 유입 채널 | 3 | aux:측정 인프라 |
|
||||
| E3 | GSC AI Overviews 노출 | 2 | aux:측정 인프라 |
|
||||
| E4 | 기준선 스팟체크 | 2 | aux:측정 인프라 |
|
||||
| F1 | 이미지 대체텍스트 | 2 | AEO7 |
|
||||
| F2 | CMS 보안·최신성 | 1 | aux:기술 위생 |
|
||||
| F3 | 페이지 무게 | 1 | aux:기술 위생 |
|
||||
| F4 | 모바일 대응 | 1 | aux:기술 위생 |
|
||||
|
||||
## 재채점 결과 (2026-09-07)
|
||||
|
||||
| 업체 | v1.0 종합 | v2.0 AEO | v2.0 GEO | 게이트 | 비고 |
|
||||
|---|---|---|---|---|---|
|
||||
| 뷰성형외과 (viewclinic) | 54/C | 42/C (검증 8/8) | 56/C (검증 8/8) | G1 통과 · G2 통과 | 사람 판정 4개 |
|
||||
| 법무법인 태하 (taeha) | 68/B | 40/D (검증 7/8, 상한 적용) | 40/D (검증 5/8, 상한 적용) | G1 실패(A2) · G2 통과 | 사람 판정 0개 |
|
||||
|
||||
### 뷰성형외과 항목별
|
||||
|
||||
| 항목 | 배점 | 점수 | 근거 | 신호 |
|
||||
|---|---|---|---|---|
|
||||
| AEO1 질문 수요 충족 | 20 | 13.3 (67%) | v1 파생 | C2=2 |
|
||||
| AEO2 답변의 직접성 | 20 | 0 (0%) | v1 파생 | C1=0 |
|
||||
| AEO3 답변 단위의 자기완결성 | 15 | 5 (33%) | v1 파생 | C8=1 |
|
||||
| AEO4 의사결정에 필요한 정보 | 15 | 6 (40%) | v1 파생 | C4=2, C5=0 |
|
||||
| AEO5 정보 구조와 가독성 | 10 | 0 (0%) | v1 파생 | C3=0 |
|
||||
| AEO6 다음 행동의 명확성 | 10 | 10 (100%) | 사람 판정 | 모든 페이지 하단에 전화 02-539-1177·주소·온라인 상담 폼이 텍스트로 반복되고(수집기 반복 줄), /counsel/reservation/ 예약 페이지와 카카오톡 채널 링크가 있다. 전화·예약·방문 3경로가 텍스트로 연결됨 |
|
||||
| AEO7 핵심 답변의 텍스트 추출 | 5 | 3.7 (73%) | v1 파생 | A3=3, F1=1 |
|
||||
| AEO8 사이트 내 답변 발견 | 5 | 4.3 (87%) | v1 파생 | A5=3, B6=2 |
|
||||
| GEO1 주장과 근거의 일치 | 20 | 6.7 (33%) | 사람 판정 | "모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)"처럼 집계 주체·시점이 붙은 주장이 일부 있으나, 모티바 그랜드 어워드·KOL·렛미인 등 대부분의 권위 신호는 이미지·슬로건 안에 있고 원문 링크가 없다. "마취과 전문의 상주 43% vs 23.1%" 비교는 출처·기준 시점 없음 |
|
||||
| GEO2 출처의 적합성과 추적성 | 15 | 15 (100%) | v1 파생 | A4=3 |
|
||||
| GEO3 전문성과 책임 주체 | 15 | 6 (40%) | v1 파생 | C7=2, B3=0 |
|
||||
| GEO4 병원 엔티티와 사실의 정합성 | 15 | 7 (47%) | v1 파생 | B2=1, B4=1, B5=0, D1=3, D2=2, D4=1, D5=2, D6=0 |
|
||||
| GEO5 사실의 최신성과 정정 | 10 | 10 (100%) | v1 파생 | C6=3 |
|
||||
| GEO6 원천 대비 추가 가치 | 10 | 6.7 (67%) | 사람 판정 | 수술 전·후 주의사항(부위별 8탭, 9,585자)·애프터케어 프로그램(4,572자)·검진센터 항목·안전수술시스템 절차는 병원 고유 정보다. 다만 표·체크리스트 형태가 없고(C5=0) 회복 일정은 문단에 흩어져 있다 |
|
||||
| GEO7 발행 관계와 투명성 | 10 | 3.3 (33%) | 사람 판정 | 푸터에 대표·사업자등록번호·주소가 있어 운영 주체는 분명하다. 그러나 홈페이지에 게시한 후기 광고가 수술비 할인 대가를 표시하지 않아 2026-07-12 공정위 시정명령·공표명령을 받았고, 이벤트·전후·후기 섹션이 정보 페이지와 구분 없이 섞여 있다 |
|
||||
| GEO8 인용 식별자와 메타데이터 정합성 | 5 | 1.7 (33%) | v1 파생 | B1=1 |
|
||||
|
||||
## 해석 주의
|
||||
|
||||
- v1.0 54/C(뷰성형외과)는 접근성·엔티티·콘텐츠·표면·측정을 한 점수로 합친 값이고, v2.0은 "답을 하는가"와 "출처가 되는가"를 나눈 값이라 서로 비교하지 않는다.
|
||||
- 사람 판정 항목(AEO6·GEO1·GEO6·GEO7)은 2026-09-07 수집기 원문을 근거로 한 초안이며 병원 확인 전이다.
|
||||
- 서포터즈 사이트를 같은 기준으로 채점하면 GEO 항목(근거 유형·검토자·확인일·정정 기록·지원 고지)이 설계 그대로 반영된다. 공식 홈페이지 vs 서포터즈 비교표는 이 기준으로 다시 만든다.
|
||||
30
scripts/gen_rubric_v2_doc.ts
Normal file
30
scripts/gen_rubric_v2_doc.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { AEO_GEO_RUBRIC } from '../src/data/aeoGeoRubric';
|
||||
import { AEO_GEO_RUBRIC_V2 } from '../src/data/aeoGeoRubricV2';
|
||||
import { DISCOVERY_RESULTS } from '../src/data/discoveryResults';
|
||||
import { scoreDiscovery } from '../src/lib/discoveryScore';
|
||||
import { scoreDiscoveryV2, v1Usage } from '../src/lib/discoveryScoreV2';
|
||||
const R = AEO_GEO_RUBRIC_V2; const v1 = new Map(AEO_GEO_RUBRIC.criteria.map((c) => [c.id, c]));
|
||||
const M: Record<string, string> = { auto: '자동', semi: '반자동', manual: '사람' };
|
||||
let md = `# INFINITH AI Discovery 채점 기준표 v2.0 (초안) · AEO / GEO 분리 지표\n\n> 코드 원본: \`src/data/aeoGeoRubricV2.ts\` · 집계: \`src/lib/discoveryScoreV2.ts\` · 타입: \`src/types/discovery.ts\` (v2 블록)\n> 상태: **${R.status}** (${R.updatedAt}). v1.0(36항목 종합 100점)은 유지하며 두 점수를 나란히 표시한다. 이 문서는 코드에서 생성한다(\`npx tsx scripts/gen_rubric_v2_doc.ts\`).\n\n`;
|
||||
md += `## 설계\n\n- AEO 답변 준비도(100): 고객의 질문에 얼마나 명확하고 충분하게 답하는가.\n- GEO 출처 준비도(100): 그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가.\n- 같은 평가 항목을 두 점수에 중복 배정하지 않는다. v1.0 실측 항목 36개도 한 곳에만 매핑한다(아래 매핑표, 중복 0·미매핑 0을 스크립트로 검사).\n- 이 배점은 INFINITH 제품용 진단 기준이며, 검색엔진의 공식 점수나 인용 확률이 아니다.\n- 항목 점수 = 파생 신호(v1.0 실측 레벨 0~3)의 가중 평균. 사람 판정(\`v2Results\`)이 있으면 우선. 미검증은 분모 제외.\n- 게이트는 점수가 아니라 상한이다. 크롤러 차단·본문 미렌더면 두 축 모두 40 이하로 표시한다.\n- v1.0의 측정 인프라(E)·기술 위생 일부(F)·속도·hreflang·오픈웹 언급은 점수 밖 부가 지표로 남긴다.\n- 등급 경계는 v1.0과 같다: A(81+) · B(61–80) · C(41–60) · D(≤40).\n\n`;
|
||||
for (const ax of R.axes) {
|
||||
md += `## ${ax.code} · ${ax.name} (${ax.nameEn}) · 100\n\n${ax.question}\n\n| ID | 항목 | 배점 | 채점 | v1.0 파생 신호(비중) | 확인 방법 | 3점 기준 |\n|---|---|---|---|---|---|---|\n`;
|
||||
for (const it of R.items.filter((i) => i.axis === ax.id)) md += `| ${it.id} | ${it.name} | ${it.weight} | ${M[it.method]} | ${it.derivedFrom.map((d) => `${d.criterionId} ${v1.get(d.criterionId)?.name ?? ''}(${d.weight})`).join(', ') || '없음 (사람 판정)'} | ${it.howToCheck} | ${it.levels[3].label} |\n`;
|
||||
md += '\n';
|
||||
}
|
||||
md += `## 게이트 (상한)\n\n| ID | 이름 | v1.0 항목 | 실패 조건 | 상한 | 적용 축 | 이유 |\n|---|---|---|---|---|---|---|\n`;
|
||||
for (const g of R.gates) md += `| ${g.id} | ${g.name} | ${g.criterionIds.join(', ')} | level ≤ ${g.failLevel} | ${g.cap} | ${g.axes.map((a) => a.toUpperCase()).join('·')} | ${g.reason} |\n`;
|
||||
md += `\n## 점수 밖 부가 지표\n\n| v1.0 항목 | 그룹 |\n|---|---|\n`;
|
||||
for (const a of R.auxiliary) md += `| ${a.criterionId} ${v1.get(a.criterionId)?.name ?? ''} | ${a.group} |\n`;
|
||||
const usage = v1Usage(R);
|
||||
md += `\n## v1.0 → v2.0 매핑표 (36항목 전수)\n\n| v1.0 | 항목 | 배점 | v2.0 위치 |\n|---|---|---|---|\n`;
|
||||
for (const c of AEO_GEO_RUBRIC.criteria) md += `| ${c.id} | ${c.name} | ${c.weight} | ${(usage.get(c.id) ?? ['—']).join(', ')} |\n`;
|
||||
md += `\n## 재채점 결과 (${R.updatedAt})\n\n| 업체 | v1.0 종합 | v2.0 AEO | v2.0 GEO | 게이트 | 비고 |\n|---|---|---|---|---|---|\n`;
|
||||
for (const [id, r] of Object.entries(DISCOVERY_RESULTS)) { const a = scoreDiscovery(AEO_GEO_RUBRIC, r); const b = scoreDiscoveryV2(R, r); md += `| ${r.clinicName} (${id}) | ${a.score}/${a.grade} | ${b.aeo.score}/${b.aeo.grade} (검증 ${b.aeo.verifiedCount}/8${b.aeo.cappedBy.length ? ', 상한 적용' : ''}) | ${b.geo.score}/${b.geo.grade} (검증 ${b.geo.verifiedCount}/8${b.geo.cappedBy.length ? ', 상한 적용' : ''}) | ${b.gates.map((g) => `${g.gate.id} ${g.passed ? '통과' : '실패(' + g.failedBy.join(',') + ')'}`).join(' · ')} | 사람 판정 ${(r.v2Results ?? []).length}개 |\n`; }
|
||||
md += `\n### 뷰성형외과 항목별\n\n| 항목 | 배점 | 점수 | 근거 | 신호 |\n|---|---|---|---|---|\n`;
|
||||
const v = scoreDiscoveryV2(R, DISCOVERY_RESULTS.viewclinic);
|
||||
for (const it of [...v.aeo.items, ...v.geo.items]) md += `| ${it.item.id} ${it.item.name} | ${it.item.weight} | ${it.pct === null ? '미검증' : `${it.earned} (${Math.round(it.pct * 100)}%)`} | ${it.basis === 'judged' ? '사람 판정' : it.basis === 'derived' ? 'v1 파생' : '—'} | ${it.signals.map((s) => `${s.criterionId}=${s.level}`).join(', ') || (it.evidence ?? '')} |\n`;
|
||||
md += `\n## 해석 주의\n\n- v1.0 54/C(뷰성형외과)는 접근성·엔티티·콘텐츠·표면·측정을 한 점수로 합친 값이고, v2.0은 "답을 하는가"와 "출처가 되는가"를 나눈 값이라 서로 비교하지 않는다.\n- 사람 판정 항목(AEO6·GEO1·GEO6·GEO7)은 2026-09-07 수집기 원문을 근거로 한 초안이며 병원 확인 전이다.\n- 서포터즈 사이트를 같은 기준으로 채점하면 GEO 항목(근거 유형·검토자·확인일·정정 기록·지원 고지)이 설계 그대로 반영된다. 공식 홈페이지 vs 서포터즈 비교표는 이 기준으로 다시 만든다.\n`;
|
||||
writeFileSync(new URL('../docs/AEO_GEO_RUBRIC_v2.md', import.meta.url), md);
|
||||
console.log('doc written', md.length);
|
||||
112
src/components/discovery/AeoGeoV2Panel.tsx
Normal file
112
src/components/discovery/AeoGeoV2Panel.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* v2.0 AEO / GEO 분리 점수 패널 (초안).
|
||||
* 두 축 각각 100점. 게이트(크롤러 접근·본문 렌더링)는 점수가 아니라 상한으로 표시한다.
|
||||
* 항목 점수는 v1.0 실측에서 파생하거나(v1 파생) 사람 판정(v2Results)에서 온다. 미검증은 분모 제외.
|
||||
*/
|
||||
import { ScoreRing } from '../report/ui/ScoreRing';
|
||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||
import type { AxisScoreV2, ItemScoreV2, OverallScoreV2, RubricV2 } from '../../types/discovery';
|
||||
|
||||
const BASIS_LABEL: Record<ItemScoreV2['basis'], string> = { derived: 'v1.0 실측 파생', judged: '사람 판정', unverified: '미검증' };
|
||||
const METHOD_LABEL = { auto: '자동', semi: '반자동', manual: '사람' } as const;
|
||||
|
||||
function ItemRow({ it }: { it: ItemScoreV2; key?: string }) {
|
||||
const pct = it.pct === null ? null : Math.round(it.pct * 100);
|
||||
const color = pct === null ? '#CBD5E1' : pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
||||
return (
|
||||
<div className="py-4 grid md:grid-cols-[200px_1fr_120px] gap-3 items-start">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-400">{it.item.id}</span>
|
||||
<p className="text-base font-bold text-[#0A1128]">{it.item.name}</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{it.item.weight}점 · {METHOD_LABEL[it.item.method]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base text-slate-700">{it.item.question}</p>
|
||||
{it.evidence && <p className="text-sm text-slate-500 mt-1">{it.evidence}</p>}
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{BASIS_LABEL[it.basis]}
|
||||
{it.signals.length > 0 && ` · ${it.signals.map((s) => `${s.criterionId}=${s.level === 'unverified' ? '미검증' : s.level}`).join(' · ')}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-base font-bold px-4 py-1.5 rounded-full" style={{ background: `${color}22`, color }}>
|
||||
{pct === null ? '미검증' : `${it.earned} / ${it.item.weight}`}
|
||||
</span>
|
||||
<div className="w-24 h-1.5 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct ?? 0}%`, background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AxisCard({ ax }: { ax: AxisScoreV2 }) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<p className="font-serif text-[28px] font-bold text-[#1D0024] tracking-[-0.01em] leading-10">{ax.axis.code} · {ax.axis.nameEn}</p>
|
||||
<h3 className="text-xl font-bold mt-1 font-sans text-[#0A1128]!">{ax.axis.name}</h3>
|
||||
<p className="text-lg text-[#4F1DA1] mt-2">{ax.axis.question}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-400">검증 {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}점</p>
|
||||
{ax.cappedBy.length > 0 && (
|
||||
<p className="text-sm font-semibold text-[#C084CF]">게이트 상한 {Math.min(...ax.cappedBy.map((g) => g.gate.cap))} (원점수 {ax.rawScore})</p>
|
||||
)}
|
||||
<p className="font-serif text-3xl font-black">{ax.grade}</p>
|
||||
</div>
|
||||
<ScoreRing score={ax.score} size={96} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base text-slate-600 mb-3">{ax.axis.description}</p>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{ax.items.map((it) => <ItemRow key={it.item.id} it={it} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: OverallScoreV2 }) {
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="aeo-geo-v2"
|
||||
title="Answer & Source Readiness"
|
||||
subtitle={`v${rubric.version} · AEO 답변 준비도와 GEO 출처 준비도를 각각 100점의 독립 지표로 본다. INFINITH 제품용 진단 기준이며 검색엔진의 공식 점수나 인용 확률이 아니다`}
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-5 mb-6">
|
||||
{[score.aeo, score.geo].map((ax) => (
|
||||
<div key={ax.axis.id} className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 flex items-center gap-6">
|
||||
<ScoreRing score={ax.score} size={120} label={ax.axis.nameEn} />
|
||||
<div>
|
||||
<p className="font-serif text-2xl font-bold text-[#1D0024]">{ax.axis.code} · {ax.axis.name}</p>
|
||||
<p className="text-base text-slate-600 mt-1">{ax.axis.question}</p>
|
||||
<p className="text-sm text-slate-400 mt-2">
|
||||
등급 <span className="font-bold text-[#0A1128]">{ax.grade}</span> · 검증 {ax.verifiedCount}/{ax.items.length}
|
||||
{ax.cappedBy.length > 0 && ` · 게이트 상한 적용 (원점수 ${ax.rawScore})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#F3F0FF] rounded-2xl p-5 mb-8">
|
||||
<p className="text-base font-bold text-[#0A1128] mb-2">게이트 (점수가 아니라 상한)</p>
|
||||
<ul className="grid md:grid-cols-2 gap-2">
|
||||
{score.gates.map((g) => (
|
||||
<li key={g.gate.id} className="text-base text-slate-700">
|
||||
<span className={`font-bold ${g.passed ? 'text-[#6C5CE7]' : 'text-[#C084CF]'}`}>{g.gate.id} {g.gate.name} · {g.passed ? '통과' : `실패 (${g.failedBy.join(', ')}) → 상한 ${g.gate.cap}`}</span>
|
||||
<span className="text-slate-500"> {g.gate.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AxisCard ax={score.aeo} />
|
||||
<AxisCard ax={score.geo} />
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
287
src/data/aeoGeoRubricV2.ts
Normal file
287
src/data/aeoGeoRubricV2.ts
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* INFINITH AI Discovery 채점 기준표 v2.0 (초안) · AEO / GEO 분리 지표
|
||||
*
|
||||
* - AEO 답변 준비도(100): 고객의 질문에 얼마나 명확하고 충분하게 답하는가
|
||||
* - GEO 출처 준비도(100): 그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가
|
||||
* - 같은 평가 항목을 두 점수에 중복 배정하지 않는다. v1.0 실측 항목(36개)도 한 곳에만 매핑한다.
|
||||
* - 이 배점은 INFINITH 제품용 진단 기준이며, 검색엔진의 공식 점수나 인용 확률이 아니다.
|
||||
*
|
||||
* 채점 원리
|
||||
* - 항목 점수 = 파생 신호(v1.0 실측 레벨 0~3)의 가중 평균 → weight × pct. 사람 판정(v2Results)이 있으면 그것을 우선한다.
|
||||
* - 미검증 항목은 분모에서 제외한다 (v1.0과 같은 정직성 규약).
|
||||
* - 게이트: 크롤러 차단·본문 미렌더는 점수가 아니라 상한이다. 읽히지 않는 사이트는 답도 출처도 될 수 없다.
|
||||
* - v1.0의 측정 인프라(E)·기술 위생 일부(F)·속도·hreflang은 점수 밖 부가 지표로 표시한다.
|
||||
*/
|
||||
import type { RubricV2 } from '../types/discovery';
|
||||
|
||||
const L = (l0: string, l1: string, l2: string, l3: string) => [
|
||||
{ level: 0 as const, label: l0 },
|
||||
{ level: 1 as const, label: l1 },
|
||||
{ level: 2 as const, label: l2 },
|
||||
{ level: 3 as const, label: l3 },
|
||||
];
|
||||
|
||||
export const AEO_GEO_RUBRIC_V2: RubricV2 = {
|
||||
version: '2.0-draft',
|
||||
updatedAt: '2026-09-07',
|
||||
status: 'draft',
|
||||
axes: [
|
||||
{
|
||||
id: 'aeo',
|
||||
code: 'AEO',
|
||||
name: '답변 준비도',
|
||||
nameEn: 'Answer Readiness',
|
||||
question: '고객의 질문에 얼마나 명확하고 충분하게 답하는가?',
|
||||
description: '질문을 고르고, 답을 먼저 쓰고, 한 단락으로 완결하고, 결정에 필요한 정보와 다음 행동까지 닿게 하는가.',
|
||||
},
|
||||
{
|
||||
id: 'geo',
|
||||
code: 'GEO',
|
||||
name: '출처 준비도',
|
||||
nameEn: 'Source Readiness',
|
||||
question: '그 답을 얼마나 검증하고 신뢰할 수 있는 출처로 사용할 수 있는가?',
|
||||
description: '주장에 근거가 붙어 있고, 누가 책임지는지 보이고, 병원 사실이 표면마다 같고, 갱신·정정 기록이 있으며, 기계가 인용 식별자를 읽을 수 있는가.',
|
||||
},
|
||||
],
|
||||
items: [
|
||||
// ───────────── AEO 답변 준비도 (100) ─────────────
|
||||
{
|
||||
id: 'AEO1',
|
||||
axis: 'aeo',
|
||||
name: '질문 수요 충족',
|
||||
nameEn: 'Question Coverage',
|
||||
question: '사람들이 실제로 묻는 질문에 답하는 페이지가 있는가',
|
||||
weight: 20,
|
||||
method: 'semi',
|
||||
derivedFrom: [{ criterionId: 'C2', weight: 1 }],
|
||||
howToCheck: '질문 뱅크(업종별 7카테고리) 대비 답이 있는 문항 비율. 자동 신호는 FAQ 섹션·FAQPage 스키마(C2). 사람 판정은 QB 실측 결과로 덮어쓴다',
|
||||
levels: L('질문형 콘텐츠 없음', '게시판형 Q&A 또는 문항 20% 미만', '주요 시술 FAQ 존재, 문항 20~60%', '카테고리 전반에 답, 문항 60% 이상'),
|
||||
},
|
||||
{
|
||||
id: 'AEO2',
|
||||
axis: 'aeo',
|
||||
name: '답변의 직접성',
|
||||
nameEn: 'Answer First',
|
||||
question: '페이지 첫 단락에 답이 먼저 나오는가',
|
||||
weight: 20,
|
||||
method: 'semi',
|
||||
derivedFrom: [{ criterionId: 'C1', weight: 1 }],
|
||||
howToCheck: '핵심 페이지 첫 200자에 [상호+지역+전문분야] 정의문 또는 질문에 대한 직접 답 존재(C1)',
|
||||
levels: L('슬로건·이미지만', '슬로건형 첫 문장', '일부 페이지에 정의문', '전 핵심 페이지가 답으로 시작'),
|
||||
},
|
||||
{
|
||||
id: 'AEO3',
|
||||
axis: 'aeo',
|
||||
name: '답변 단위의 자기완결성',
|
||||
nameEn: 'Self-contained Passages',
|
||||
question: '한 단락만 떼어내도 누가·어디서·무엇을이 들어 있는가',
|
||||
weight: 15,
|
||||
method: 'manual',
|
||||
derivedFrom: [{ criterionId: 'C8', weight: 1 }],
|
||||
howToCheck: '핵심 페이지 단락 샘플 10개 중 상호·시술명이 포함된 비율(C8)',
|
||||
levels: L('0~20%', '20~50%', '50~80%', '80% 이상'),
|
||||
},
|
||||
{
|
||||
id: 'AEO4',
|
||||
axis: 'aeo',
|
||||
name: '의사결정에 필요한 정보',
|
||||
nameEn: 'Decision Information',
|
||||
question: '가격대·시간·회복·조건처럼 결정에 쓰는 정보가 텍스트로 있는가',
|
||||
weight: 15,
|
||||
method: 'semi',
|
||||
derivedFrom: [{ criterionId: 'C4', weight: 0.6 }, { criterionId: 'C5', weight: 0.4 }],
|
||||
howToCheck: '시술 페이지의 수치 밀도(C4)와 HTML 표·비교 구조(C5)',
|
||||
levels: L('수치·표 없음', '마케팅 문구 속 수치만', '시술별 일부 수치 또는 표', '시술별 수치 + 비교표'),
|
||||
},
|
||||
{
|
||||
id: 'AEO5',
|
||||
axis: 'aeo',
|
||||
name: '정보 구조와 가독성',
|
||||
nameEn: 'Structure & Readability',
|
||||
question: '제목이 주제를 서술하고 계층이 논리적인가',
|
||||
weight: 10,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'C3', weight: 1 }],
|
||||
howToCheck: 'H1 1개·서술형, H2 논리 계층, 중복 H2 비율(C3)',
|
||||
levels: L('H1 없음 또는 다수 라벨형', 'H1 다수·라벨형', 'H1 1개, H2 일부 라벨형', 'H1 1개 서술형, H2 논리 계층'),
|
||||
},
|
||||
{
|
||||
id: 'AEO6',
|
||||
axis: 'aeo',
|
||||
name: '다음 행동의 명확성',
|
||||
nameEn: 'Next Action',
|
||||
question: '답을 읽은 사람이 무엇을 하면 되는지(예약·전화·방문) 바로 보이는가',
|
||||
weight: 10,
|
||||
method: 'manual',
|
||||
derivedFrom: [],
|
||||
howToCheck: '핵심 페이지에서 예약 경로·전화·위치가 본문 텍스트로 연결되는지. 팝업·이미지 버튼만 있으면 감점. v1.0에 대응 항목이 없어 사람 판정',
|
||||
levels: L('행동 경로 없음', '팝업·이미지 버튼만', '텍스트 링크 1경로', '전화·예약·방문 3경로가 텍스트로'),
|
||||
},
|
||||
{
|
||||
id: 'AEO7',
|
||||
axis: 'aeo',
|
||||
name: '핵심 답변의 텍스트 추출',
|
||||
nameEn: 'Text Extractability',
|
||||
question: '답이 이미지가 아니라 텍스트로 존재하고 서버가 렌더하는가',
|
||||
weight: 5,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'A3', weight: 0.6 }, { criterionId: 'F1', weight: 0.4 }],
|
||||
howToCheck: 'JS 미실행 HTML 본문 비율(A3) + 이미지 alt 누락률(F1)',
|
||||
levels: L('본문 전무 또는 이미지 글자', '30% 미만 또는 alt 절반 누락', '30~80%', '80% 이상 + alt 정비'),
|
||||
},
|
||||
{
|
||||
id: 'AEO8',
|
||||
axis: 'aeo',
|
||||
name: '사이트 내 답변 발견',
|
||||
nameEn: 'Discoverability',
|
||||
question: '크롤러가 답이 있는 페이지를 찾아갈 수 있는가',
|
||||
weight: 5,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'A5', weight: 0.6 }, { criterionId: 'B6', weight: 0.4 }],
|
||||
howToCheck: 'sitemap 선언·lastmod(A5) + canonical 정합(B6)',
|
||||
levels: L('sitemap·canonical 없음', '있으나 미선언·불일치', '선언·200·canonical', '+ lastmod 최신·파라미터 정리'),
|
||||
},
|
||||
|
||||
// ───────────── GEO 출처 준비도 (100) ─────────────
|
||||
{
|
||||
id: 'GEO1',
|
||||
axis: 'geo',
|
||||
name: '주장과 근거의 일치',
|
||||
nameEn: 'Claims Backed by Evidence',
|
||||
question: '수치·수상·효과 주장마다 근거(원문·출처·조건)가 붙어 있는가',
|
||||
weight: 20,
|
||||
method: 'manual',
|
||||
derivedFrom: [],
|
||||
howToCheck: '핵심 페이지의 주장 문장 샘플 10개 중 출처·조건·시점이 붙은 비율. "1위·최다·안전" 류 주장에 기준 시점·집계 주체가 있는지. v1.0에 대응 항목이 없어 사람 판정',
|
||||
levels: L('근거 없는 주장 위주', '일부 주장에 출처', '대부분 출처·조건 명시', '전부 원문 링크·시점·조건 명시'),
|
||||
},
|
||||
{
|
||||
id: 'GEO2',
|
||||
axis: 'geo',
|
||||
name: '출처의 적합성과 추적성',
|
||||
nameEn: 'Traceable Source',
|
||||
question: '인용된 URL이 정본으로 수렴하고 시간이 지나도 같은 곳을 가리키는가',
|
||||
weight: 15,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'A4', weight: 1 }],
|
||||
howToCheck: 'http/www 변형의 301 정본 수렴(A4). 사람 판정으로 페이지 URL 안정성·작성자·발행일 표기 추가',
|
||||
levels: L('HTTPS 없음', '변형 간 중복', '일부 302', '전부 301 정본 수렴'),
|
||||
},
|
||||
{
|
||||
id: 'GEO3',
|
||||
axis: 'geo',
|
||||
name: '전문성과 책임 주체',
|
||||
nameEn: 'Expertise & Accountability',
|
||||
question: '누가 썼고 누가 의학적으로 책임지는지 텍스트와 스키마로 보이는가',
|
||||
weight: 15,
|
||||
method: 'semi',
|
||||
derivedFrom: [{ criterionId: 'C7', weight: 0.6 }, { criterionId: 'B3', weight: 0.4 }],
|
||||
howToCheck: '전문가 자격·경력 텍스트(C7) + Physician/Attorney 스키마(B3)',
|
||||
levels: L('전문가 정보 없음', '이름·사진만', '자격·경력 텍스트', '+ 스키마·sameAs·검토 표기'),
|
||||
},
|
||||
{
|
||||
id: 'GEO4',
|
||||
axis: 'geo',
|
||||
name: '병원 엔티티와 사실의 정합성',
|
||||
nameEn: 'Entity Consistency',
|
||||
question: '이 병원이 누구인지 사이트 안과 밖(강남언니·GBP·플레이스)에서 같은 사실로 확정되는가',
|
||||
weight: 15,
|
||||
method: 'semi',
|
||||
derivedFrom: [
|
||||
{ criterionId: 'B2', weight: 0.2 },
|
||||
{ criterionId: 'B4', weight: 0.15 },
|
||||
{ criterionId: 'B5', weight: 0.1 },
|
||||
{ criterionId: 'D1', weight: 0.2 },
|
||||
{ criterionId: 'D2', weight: 0.1 },
|
||||
{ criterionId: 'D4', weight: 0.15 },
|
||||
{ criterionId: 'D5', weight: 0.05 },
|
||||
{ criterionId: 'D6', weight: 0.05 },
|
||||
],
|
||||
howToCheck: '업종 스키마(B2)·사이트 내 NAP(B4)·sameAs(B5)·버티컬 프로필(D1)·GBP(D2)·표면 간 NAP(D4)·플레이스(D5)·Wikidata(D6)',
|
||||
levels: L('표면마다 다른 병원처럼 보임', '주요 표면 존재, NAP 불일치', '표면·스키마 대체로 일치', '전 표면 NAP 완전 일치 + 스키마 연결'),
|
||||
},
|
||||
{
|
||||
id: 'GEO5',
|
||||
axis: 'geo',
|
||||
name: '사실의 최신성과 정정',
|
||||
nameEn: 'Freshness & Corrections',
|
||||
question: '언제 확인한 사실인지 적혀 있고, 틀린 것을 고친 기록이 있는가',
|
||||
weight: 10,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'C6', weight: 1 }],
|
||||
howToCheck: 'dateModified·sitemap lastmod·본문 갱신일(C6). 사람 판정으로 확인일 병기·정정 기록 페이지 유무 추가',
|
||||
levels: L('2년 이상 갱신 없음', '1년 이상', '6개월 이내', '3개월 이내 + 확인일·정정 기록'),
|
||||
},
|
||||
{
|
||||
id: 'GEO6',
|
||||
axis: 'geo',
|
||||
name: '원천 대비 추가 가치',
|
||||
nameEn: 'Added Value over Source',
|
||||
question: '다른 곳에 있는 문장을 옮긴 것이 아니라 여기서만 얻는 정보(체크리스트·조건 표·실측)가 있는가',
|
||||
weight: 10,
|
||||
method: 'manual',
|
||||
derivedFrom: [],
|
||||
howToCheck: '홈페이지·타 사이트 문장과의 40자 연속 일치율, 원천 콘텐츠 5유형(체크리스트·조건 표·협진 동선·발언 인용·실측) 유무. 공식 홈페이지 자체를 진단할 때는 "제조사·학회 자료를 옮기지 않고 병원 고유 정보를 썼는가"로 본다',
|
||||
levels: L('복제·재진술 위주', '일부 고유 정보', '고유 정보 다수', '체크리스트·표·실측 등 원천 유형 3개 이상'),
|
||||
},
|
||||
{
|
||||
id: 'GEO7',
|
||||
axis: 'geo',
|
||||
name: '발행 관계와 투명성',
|
||||
nameEn: 'Disclosure',
|
||||
question: '누가 운영하고 누구의 지원을 받는지, 광고와 정보가 구분되는지 밝히는가',
|
||||
weight: 10,
|
||||
method: 'manual',
|
||||
derivedFrom: [],
|
||||
howToCheck: '운영 주체·연락처·사업자 정보, 경제적 이해관계 고지(첫 부분), 후기·이벤트가 정보와 구분되는지. 공정위 추천·보증 심사지침 기준',
|
||||
levels: L('운영 주체 불명', '사업자 정보만', '운영 주체·연락처·고지 있음', '고지가 첫 부분에, 정보·광고 구분 명확'),
|
||||
},
|
||||
{
|
||||
id: 'GEO8',
|
||||
axis: 'geo',
|
||||
name: '인용 식별자와 메타데이터 정합성',
|
||||
nameEn: 'Citation Metadata',
|
||||
question: '기계가 읽는 식별자(JSON-LD·메타)가 유효하고 화면 내용과 같은가',
|
||||
weight: 5,
|
||||
method: 'auto',
|
||||
derivedFrom: [{ criterionId: 'B1', weight: 1 }],
|
||||
howToCheck: 'ld+json 전 블록 파싱 성공(B1). 사람 판정으로 headline·dateModified·author가 화면과 일치하는지 추가',
|
||||
levels: L('전부 무효 또는 없음', '일부 무효', '전부 유효', '전부 유효 + 화면과 일치'),
|
||||
},
|
||||
],
|
||||
gates: [
|
||||
{
|
||||
id: 'G1',
|
||||
name: 'AI 크롤러 접근',
|
||||
criterionIds: ['A1', 'A2'],
|
||||
failLevel: 1,
|
||||
cap: 40,
|
||||
axes: ['aeo', 'geo'],
|
||||
reason: '검색용 AI 봇이 차단되거나 CDN이 챌린지를 걸면 답도 출처도 될 수 없다. 점수 대신 상한 40으로 표시한다.',
|
||||
},
|
||||
{
|
||||
id: 'G2',
|
||||
name: '본문 렌더링',
|
||||
criterionIds: ['A3'],
|
||||
failLevel: 0,
|
||||
cap: 40,
|
||||
axes: ['aeo', 'geo'],
|
||||
reason: 'JS 미실행 HTML에 본문이 없으면 크롤러에게 빈 페이지다.',
|
||||
},
|
||||
],
|
||||
auxiliary: [
|
||||
{ criterionId: 'A6', group: '색인·발견' },
|
||||
{ criterionId: 'A7', group: '속도·크롤 예산' },
|
||||
{ criterionId: 'B7', group: '다국어' },
|
||||
{ criterionId: 'D3', group: '오픈웹 언급' },
|
||||
{ criterionId: 'E1', group: '측정 인프라' },
|
||||
{ criterionId: 'E2', group: '측정 인프라' },
|
||||
{ criterionId: 'E3', group: '측정 인프라' },
|
||||
{ criterionId: 'E4', group: '측정 인프라' },
|
||||
{ criterionId: 'F2', group: '기술 위생' },
|
||||
{ criterionId: 'F3', group: '기술 위생' },
|
||||
{ criterionId: 'F4', group: '기술 위생' },
|
||||
],
|
||||
};
|
||||
|
||||
export const getItemV2 = (id: string) => AEO_GEO_RUBRIC_V2.items.find((i) => i.id === id);
|
||||
@ -158,5 +158,12 @@ export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
|
||||
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
|
||||
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' },
|
||||
],
|
||||
// v2.0(초안) 사람 판정. v1.0에 대응 항목이 없는 4개만. 근거는 2026-09-07 수집기(evidence/viewclinic) 원문. 병원 확인 전 초안이다.
|
||||
v2Results: [
|
||||
{ criterionId: 'AEO6', level: 3, evidence: '모든 페이지 하단에 전화 02-539-1177·주소·온라인 상담 폼이 텍스트로 반복되고(수집기 반복 줄), /counsel/reservation/ 예약 페이지와 카카오톡 채널 링크가 있다. 전화·예약·방문 3경로가 텍스트로 연결됨', source: 'evidence/viewclinic/index.json (reservation 5, direction 2)' },
|
||||
{ criterionId: 'GEO1', level: 1, evidence: '"모티바 보형물 국내 사용량 1위(2023, 모티바코리아 전체 수술량 기준)"처럼 집계 주체·시점이 붙은 주장이 일부 있으나, 모티바 그랜드 어워드·KOL·렛미인 등 대부분의 권위 신호는 이미지·슬로건 안에 있고 원문 링크가 없다. "마취과 전문의 상주 43% vs 23.1%" 비교는 출처·기준 시점 없음', source: 'https://www.viewclinic.com/breast/breast-augmentation/motiva/ · /system/safe-surgery/ (2026-09-07)' },
|
||||
{ criterionId: 'GEO6', level: 2, evidence: '수술 전·후 주의사항(부위별 8탭, 9,585자)·애프터케어 프로그램(4,572자)·검진센터 항목·안전수술시스템 절차는 병원 고유 정보다. 다만 표·체크리스트 형태가 없고(C5=0) 회복 일정은 문단에 흩어져 있다', source: 'evidence/viewclinic/pages/system-precautions.json · system-after-care.json' },
|
||||
{ criterionId: 'GEO7', level: 1, evidence: '푸터에 대표·사업자등록번호·주소가 있어 운영 주체는 분명하다. 그러나 홈페이지에 게시한 후기 광고가 수술비 할인 대가를 표시하지 않아 2026-07-12 공정위 시정명령·공표명령을 받았고, 이벤트·전후·후기 섹션이 정보 페이지와 구분 없이 섞여 있다', source: '공정위 2026-07-12 보도 (한국일보 A2026071211050002674) · evidence/viewclinic/facts.draft.json' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
73
src/lib/discoveryScoreV2.ts
Normal file
73
src/lib/discoveryScoreV2.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* v2.0 AEO / GEO 분리 집계
|
||||
*
|
||||
* - 항목 pct = 사람 판정(v2Results)이 있으면 level/3, 없으면 파생 신호(v1.0 결과)의 가중 평균(level/3)
|
||||
* - 파생 신호가 전부 미검증이고 사람 판정도 없으면 미검증 → 분모 제외
|
||||
* - 축 점수 = Σ(weight × pct) / Σ(검증 항목 weight) × 100, 게이트 실패 시 cap 이하로 제한
|
||||
*/
|
||||
import type {
|
||||
AxisScoreV2,
|
||||
CriterionLevel,
|
||||
CriterionResult,
|
||||
DiscoveryResult,
|
||||
GateResultV2,
|
||||
ItemScoreV2,
|
||||
OverallScoreV2,
|
||||
RubricAxisV2,
|
||||
RubricGateV2,
|
||||
RubricItemV2,
|
||||
RubricV2,
|
||||
} from '../types/discovery';
|
||||
import { gradeFromScore } from './discoveryScore';
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function scoreItem(item: RubricItemV2, byId: Map<string, CriterionResult>, judged: Map<string, CriterionResult>): ItemScoreV2 {
|
||||
const j = judged.get(item.id);
|
||||
if (j && j.level !== 'unverified') {
|
||||
return { item, pct: j.level / 3, earned: round1((item.weight * j.level) / 3), basis: 'judged', signals: [], evidence: j.evidence };
|
||||
}
|
||||
const signals = item.derivedFrom.map((d) => ({ criterionId: d.criterionId, level: byId.get(d.criterionId)?.level ?? ('unverified' as const), weight: d.weight }));
|
||||
const verified = signals.filter((s) => s.level !== 'unverified');
|
||||
if (!verified.length) return { item, pct: null, earned: 0, basis: 'unverified', signals, evidence: j?.evidence };
|
||||
const wsum = verified.reduce((s, x) => s + x.weight, 0);
|
||||
const pct = verified.reduce((s, x) => s + (x.weight * (x.level as CriterionLevel)) / 3, 0) / wsum;
|
||||
return { item, pct, earned: round1(item.weight * pct), basis: 'derived', signals, evidence: j?.evidence };
|
||||
}
|
||||
|
||||
function evalGate(gate: RubricGateV2, byId: Map<string, CriterionResult>): GateResultV2 {
|
||||
const failedBy = gate.criterionIds.filter((id) => {
|
||||
const r = byId.get(id);
|
||||
return r && r.level !== 'unverified' && r.level <= gate.failLevel;
|
||||
});
|
||||
return { gate, passed: failedBy.length === 0, failedBy };
|
||||
}
|
||||
|
||||
function scoreAxis(axis: RubricAxisV2, rubric: RubricV2, byId: Map<string, CriterionResult>, judged: Map<string, CriterionResult>, gates: GateResultV2[]): AxisScoreV2 {
|
||||
const items = rubric.items.filter((i) => i.axis === axis.id).map((i) => scoreItem(i, byId, judged));
|
||||
const verified = items.filter((i) => i.pct !== null);
|
||||
const possible = verified.reduce((s, i) => s + i.item.weight, 0);
|
||||
const earned = verified.reduce((s, i) => s + i.earned, 0);
|
||||
const rawScore = possible === 0 ? 0 : Math.round((earned / possible) * 100);
|
||||
const cappedBy = gates.filter((g) => !g.passed && g.gate.axes.includes(axis.id));
|
||||
const cap = cappedBy.length ? Math.min(...cappedBy.map((g) => g.gate.cap)) : 100;
|
||||
const score = Math.min(rawScore, cap);
|
||||
return { axis, score, rawScore, earned: round1(earned), possible, grade: gradeFromScore(score), items, verifiedCount: verified.length, cappedBy };
|
||||
}
|
||||
|
||||
export function scoreDiscoveryV2(rubric: RubricV2, result: DiscoveryResult): OverallScoreV2 {
|
||||
const byId = new Map(result.results.map((r) => [r.criterionId, r]));
|
||||
const judged = new Map((result.v2Results ?? []).map((r) => [r.criterionId, r]));
|
||||
const gates = rubric.gates.map((g) => evalGate(g, byId));
|
||||
const [aeoAxis, geoAxis] = [rubric.axes.find((a) => a.id === 'aeo')!, rubric.axes.find((a) => a.id === 'geo')!];
|
||||
return { aeo: scoreAxis(aeoAxis, rubric, byId, judged, gates), geo: scoreAxis(geoAxis, rubric, byId, judged, gates), gates };
|
||||
}
|
||||
|
||||
/** v1.0 항목이 v2.0 어디에 쓰이는지 (매핑표·중복 검사용) */
|
||||
export function v1Usage(rubric: RubricV2): Map<string, string[]> {
|
||||
const m = new Map<string, string[]>();
|
||||
for (const it of rubric.items) for (const d of it.derivedFrom) m.set(d.criterionId, [...(m.get(d.criterionId) ?? []), it.id]);
|
||||
for (const g of rubric.gates) for (const id of g.criterionIds) m.set(id, [...(m.get(id) ?? []), g.id]);
|
||||
for (const a of rubric.auxiliary) m.set(a.criterionId, [...(m.get(a.criterionId) ?? []), `aux:${a.group}`]);
|
||||
return m;
|
||||
}
|
||||
@ -3,6 +3,9 @@ import type { ComponentType, ReactNode } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { motion } from 'motion/react';
|
||||
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
@ -78,6 +81,10 @@ export default function DiscoveryReportPage() {
|
||||
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
|
||||
[result],
|
||||
);
|
||||
const overallV2 = useMemo(
|
||||
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
|
||||
[result],
|
||||
);
|
||||
|
||||
if (!result || !overall) {
|
||||
return (
|
||||
@ -143,6 +150,9 @@ export default function DiscoveryReportPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 1b. v2.0 AEO / GEO 분리 점수 (light) ── */}
|
||||
{overallV2 && <AeoGeoV2Panel rubric={AEO_GEO_RUBRIC_V2} score={overallV2} />}
|
||||
|
||||
{/* ── 2. 핵심 발견 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="findings"
|
||||
|
||||
@ -103,6 +103,8 @@ export interface DiscoveryResult {
|
||||
actions: ActionItem[];
|
||||
/** 하지 않을 것 + 이유 */
|
||||
notDoing: { item: string; reason: string }[];
|
||||
/** v2.0 항목에 대한 사람 판정. 있으면 파생 계산보다 우선한다. criterionId = v2 항목 id (AEO1…GEO8) */
|
||||
v2Results?: CriterionResult[];
|
||||
}
|
||||
|
||||
/** 집계 결과 */
|
||||
@ -123,3 +125,100 @@ export interface OverallScore {
|
||||
categories: CategoryScore[];
|
||||
grade: 'A' | 'B' | 'C' | 'D';
|
||||
}
|
||||
|
||||
/* ────────────────────────── v2.0: AEO / GEO 분리 지표 ────────────────────────── */
|
||||
|
||||
/** v2.0 축. AEO = 답변 준비도, GEO = 출처 준비도. 각각 100점 독립 지표 */
|
||||
export type AxisId = 'aeo' | 'geo';
|
||||
|
||||
/** v2 항목이 v1.0 실측 항목에서 어떻게 파생되는지. weight는 항목 내 상대 비중 */
|
||||
export interface DerivedSignal {
|
||||
criterionId: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface RubricItemV2 {
|
||||
id: string;
|
||||
axis: AxisId;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
/** 이 항목이 답하려는 질문 (고객 화면 문장) */
|
||||
question: string;
|
||||
weight: number;
|
||||
method: EvidenceMethod;
|
||||
/** v1.0 실측 항목에서 파생. 비어 있으면 사람 판정(v2Results)만으로 채점 */
|
||||
derivedFrom: DerivedSignal[];
|
||||
howToCheck: string;
|
||||
levels: RubricLevelSpec[];
|
||||
}
|
||||
|
||||
/** 게이트: 점수가 아니라 상한. 실패하면 해당 축 점수를 cap 이하로 제한한다 */
|
||||
export interface RubricGateV2 {
|
||||
id: string;
|
||||
name: string;
|
||||
/** v1.0 항목 중 하나라도 failLevel 이하이면 실패 */
|
||||
criterionIds: string[];
|
||||
failLevel: CriterionLevel;
|
||||
cap: number;
|
||||
axes: AxisId[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RubricAxisV2 {
|
||||
id: AxisId;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
question: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RubricV2 {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
status: 'draft' | 'active';
|
||||
axes: RubricAxisV2[];
|
||||
items: RubricItemV2[];
|
||||
gates: RubricGateV2[];
|
||||
/** 점수에 넣지 않고 별도 표시하는 v1.0 항목 (측정 인프라·기술 위생 등) */
|
||||
auxiliary: { criterionId: string; group: string }[];
|
||||
}
|
||||
|
||||
export interface ItemScoreV2 {
|
||||
item: RubricItemV2;
|
||||
/** 0~1. null = 미검증(분모 제외) */
|
||||
pct: number | null;
|
||||
earned: number;
|
||||
/** 'derived' = v1 실측에서 계산, 'judged' = 사람 판정(v2Results), 'unverified' */
|
||||
basis: 'derived' | 'judged' | 'unverified';
|
||||
/** 파생에 쓰인 v1 항목과 레벨 */
|
||||
signals: { criterionId: string; level: CriterionLevel | 'unverified'; weight: number }[];
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
export interface GateResultV2 {
|
||||
gate: RubricGateV2;
|
||||
passed: boolean;
|
||||
/** 판정 근거가 된 v1 항목 (미검증이면 통과로 본다) */
|
||||
failedBy: string[];
|
||||
}
|
||||
|
||||
export interface AxisScoreV2 {
|
||||
axis: RubricAxisV2;
|
||||
/** 0~100. 검증 항목 기준 정규화 후 게이트 상한 적용 */
|
||||
score: number;
|
||||
/** 게이트 적용 전 점수 */
|
||||
rawScore: number;
|
||||
earned: number;
|
||||
possible: number;
|
||||
grade: 'A' | 'B' | 'C' | 'D';
|
||||
items: ItemScoreV2[];
|
||||
verifiedCount: number;
|
||||
cappedBy: GateResultV2[];
|
||||
}
|
||||
|
||||
export interface OverallScoreV2 {
|
||||
aeo: AxisScoreV2;
|
||||
geo: AxisScoreV2;
|
||||
gates: GateResultV2[];
|
||||
}
|
||||
|
||||
@ -23,5 +23,12 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "supabase"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"supabase",
|
||||
"supporters",
|
||||
"templates",
|
||||
"evidence"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user