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:
Haewon Kam 2026-09-07 15:12:35 +09:00
parent e52331a239
commit c02de9ea51
9 changed files with 769 additions and 1 deletions

143
docs/AEO_GEO_RUBRIC_v2.md Normal file
View 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(6180) · C(4160) · 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 서포터즈 비교표는 이 기준으로 다시 만든다.

View 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(6180) · C(4160) · 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);

View 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
View 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);

View File

@ -158,5 +158,12 @@ export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' }, { item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' }, { 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' },
],
}; };

View 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;
}

View File

@ -3,6 +3,9 @@ import type { ComponentType, ReactNode } from 'react';
import { useParams, Link } from 'react-router'; import { useParams, Link } from 'react-router';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric'; 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 { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore'; import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type { import type {
@ -78,6 +81,10 @@ export default function DiscoveryReportPage() {
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null), () => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
[result], [result],
); );
const overallV2 = useMemo(
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
[result],
);
if (!result || !overall) { if (!result || !overall) {
return ( return (
@ -143,6 +150,9 @@ export default function DiscoveryReportPage() {
</div> </div>
</section> </section>
{/* ── 1b. v2.0 AEO / GEO 분리 점수 (light) ── */}
{overallV2 && <AeoGeoV2Panel rubric={AEO_GEO_RUBRIC_V2} score={overallV2} />}
{/* ── 2. 핵심 발견 (light) ── */} {/* ── 2. 핵심 발견 (light) ── */}
<SectionWrapper <SectionWrapper
id="findings" id="findings"

View File

@ -103,6 +103,8 @@ export interface DiscoveryResult {
actions: ActionItem[]; actions: ActionItem[];
/** 하지 않을 것 + 이유 */ /** 하지 않을 것 + 이유 */
notDoing: { item: string; reason: string }[]; notDoing: { item: string; reason: string }[];
/** v2.0 항목에 대한 사람 판정. 있으면 파생 계산보다 우선한다. criterionId = v2 항목 id (AEO1…GEO8) */
v2Results?: CriterionResult[];
} }
/** 집계 결과 */ /** 집계 결과 */
@ -123,3 +125,100 @@ export interface OverallScore {
categories: CategoryScore[]; categories: CategoryScore[];
grade: 'A' | 'B' | 'C' | 'D'; 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[];
}

View File

@ -23,5 +23,12 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noEmit": true "noEmit": true
}, },
"exclude": ["node_modules", "dist", "supabase"] "exclude": [
"node_modules",
"dist",
"supabase",
"supporters",
"templates",
"evidence"
]
} }