diff --git a/docs/AEO_GEO_RUBRIC_v2.md b/docs/AEO_GEO_RUBRIC_v2.md new file mode 100644 index 0000000..252aa1d --- /dev/null +++ b/docs/AEO_GEO_RUBRIC_v2.md @@ -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 서포터즈 비교표는 이 기준으로 다시 만든다. diff --git a/scripts/gen_rubric_v2_doc.ts b/scripts/gen_rubric_v2_doc.ts new file mode 100644 index 0000000..1c83e65 --- /dev/null +++ b/scripts/gen_rubric_v2_doc.ts @@ -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 = { 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); diff --git a/src/components/discovery/AeoGeoV2Panel.tsx b/src/components/discovery/AeoGeoV2Panel.tsx new file mode 100644 index 0000000..b5d34da --- /dev/null +++ b/src/components/discovery/AeoGeoV2Panel.tsx @@ -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 = { 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 ( +
+
+ {it.item.id} +

{it.item.name}

+

{it.item.weight}점 · {METHOD_LABEL[it.item.method]}

+
+
+

{it.item.question}

+ {it.evidence &&

{it.evidence}

} +

+ {BASIS_LABEL[it.basis]} + {it.signals.length > 0 && ` · ${it.signals.map((s) => `${s.criterionId}=${s.level === 'unverified' ? '미검증' : s.level}`).join(' · ')}`} +

+
+
+ + {pct === null ? '미검증' : `${it.earned} / ${it.item.weight}`} + +
+
+
+
+
+ ); +} + +function AxisCard({ ax }: { ax: AxisScoreV2 }) { + return ( +
+
+
+

{ax.axis.code} · {ax.axis.nameEn}

+

{ax.axis.name}

+

{ax.axis.question}

+
+
+
+

검증 {ax.verifiedCount}/{ax.items.length} · {ax.earned} / {ax.possible}점

+ {ax.cappedBy.length > 0 && ( +

게이트 상한 {Math.min(...ax.cappedBy.map((g) => g.gate.cap))} (원점수 {ax.rawScore})

+ )} +

{ax.grade}

+
+ +
+
+

{ax.axis.description}

+
+ {ax.items.map((it) => )} +
+
+ ); +} + +export function AeoGeoV2Panel({ rubric, score }: { rubric: RubricV2; score: OverallScoreV2 }) { + return ( + +
+ {[score.aeo, score.geo].map((ax) => ( +
+ +
+

{ax.axis.code} · {ax.axis.name}

+

{ax.axis.question}

+

+ 등급 {ax.grade} · 검증 {ax.verifiedCount}/{ax.items.length} + {ax.cappedBy.length > 0 && ` · 게이트 상한 적용 (원점수 ${ax.rawScore})`} +

+
+
+ ))} +
+ +
+

게이트 (점수가 아니라 상한)

+
    + {score.gates.map((g) => ( +
  • + {g.gate.id} {g.gate.name} · {g.passed ? '통과' : `실패 (${g.failedBy.join(', ')}) → 상한 ${g.gate.cap}`} + {g.gate.reason} +
  • + ))} +
+
+ +
+ + +
+
+ ); +} diff --git a/src/data/aeoGeoRubricV2.ts b/src/data/aeoGeoRubricV2.ts new file mode 100644 index 0000000..72651f4 --- /dev/null +++ b/src/data/aeoGeoRubricV2.ts @@ -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); diff --git a/src/data/discovery_viewclinic.ts b/src/data/discovery_viewclinic.ts index 464e532..064e878 100644 --- a/src/data/discovery_viewclinic.ts +++ b/src/data/discovery_viewclinic.ts @@ -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' }, + ], }; diff --git a/src/lib/discoveryScoreV2.ts b/src/lib/discoveryScoreV2.ts new file mode 100644 index 0000000..12e4305 --- /dev/null +++ b/src/lib/discoveryScoreV2.ts @@ -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, judged: Map): 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): 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, judged: Map, 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 { + const m = new Map(); + 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; +} diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx index cd2d751..f87c87c 100644 --- a/src/pages/DiscoveryReportPage.tsx +++ b/src/pages/DiscoveryReportPage.tsx @@ -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() {
+ {/* ── 1b. v2.0 AEO / GEO 분리 점수 (light) ── */} + {overallV2 && } + {/* ── 2. 핵심 발견 (light) ── */}