feat: AI Discovery AEO/GEO 채점 기준표 v1.0 + /discovery/:id 리포트 (뷰성형외과 44점)

- 채점 기준표: 6 카테고리 36 항목 100점, 항목별 근거등급/통제/채점방식/howToCheck
- 집계: 미검증 항목 분모 제외, llms.txt 항목 제외
- 뷰성형외과 실측 결과 + 핵심 발견 5 + 액션 7 + 하지 않을 것 5
- DiscoveryReportPage: INFINITH 디자인 토큰, 본문 +2pt, 판정 배지/우선순위 +4pt 볼드, keep-all
- SeverityBadge className prop 추가
- Badge.tsx 복원 (PricingPage import 누락으로 빌드 깨져 있던 것)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-08-28 10:26:29 +09:00
parent 7251c6d153
commit ef4d8218e9
9 changed files with 1469 additions and 2 deletions

91
docs/AEO_GEO_RUBRIC.md Normal file
View File

@ -0,0 +1,91 @@
# INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0
> 코드 원본: `src/data/aeoGeoRubric.ts` · 타입: `src/types/discovery.ts` · 집계: `src/lib/discoveryScore.ts`
> 리포트 페이지: `/discovery/:id` (`src/pages/DiscoveryReportPage.tsx`)
> 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO KDD 2024 · C-SEO Bench NeurIPS 2025)
## 채점 방식
- 항목마다 **level 0~3** (없음/치명 · 미흡 · 양호 · 우수) → `weight × level/3`
- **미검증(unverified)** 항목은 0점이 아니라 **분모에서 제외**한다. 확인 못 한 것을 감점하면 점수가 거짓이 된다.
- 전체 점수 = 검증 항목 획득 / 검증 항목 배점 × 100
- 등급: A(81+) · B(61–80) · C(41–60) · D(≤40). ScoreRing 색 임계값과 동일
- 항목마다 **근거 등급**(실측/학술/벤더/추정) · **통제 가능성**(직접/영향/불가) · **채점 방식**(자동/반자동/수동)을 명시한다.
## 배점 철학
| 원칙 | 반영 |
|---|---|
| "차단되면 절대 인용될 수 없다"는 논쟁의 여지가 없다 | A 접근성 20 · B 엔티티 20. 크롤러 접근에 무게 |
| GEO 콘텐츠 전술의 효과는 학술적으로 논쟁 중 | C 콘텐츠 25. 배점은 크지만 "좋은 콘텐츠 원칙"으로만 설명. "X% 올려드립니다" 금지 |
| 병의원 AI 인용은 강남언니(`ai-input=yes`) 등 버티컬이 100% 장악 (실측) | D1 강남언니 6점. 단일 항목 최고 배점 |
| 네이버 자산은 글로벌 AI에 기여도 0 (robots.txt 전면 차단) | D5 네이버 플레이스는 2점, "AI 브리핑 전용"으로 한정 |
| llms.txt는 위약 (Google 효과 0, 채택률 0.13%) | 항목에서 **제외** |
| 개선을 증명 못 하면 상품이 아니다 | E 측정 인프라 10 |
## 카테고리 (100점)
| 코드 | 카테고리 | 배점 | 무엇을 보나 |
|---|---|---|---|
| A | AI 크롤러 접근성 | 20 | 검색봇 허용(학습봇과 구분) · CDN 차단 · SSR · HTTPS · sitemap · Bing 색인 · 속도 |
| B | 엔티티 식별·구조화 | 20 | JSON-LD 유효성 · MedicalClinic · Physician · NAP 일관성 · sameAs · canonical · hreflang |
| C | 답변 인용 가능 콘텐츠 | 25 | 정의문 · FAQ · 제목 계층 · 구체 수치 · 표 · 신선도 · 의료진 E-E-A-T · 패시지 자기완결성 |
| D | AI 인용 표면 확보 | 20 | 강남언니 · GBP · 오픈웹 언급 · 표면 간 NAP · 네이버 플레이스 · Wikidata |
| E | AI 가시성 측정 인프라 | 10 | 크롤러 로그(IP 검증) · GA4 AI 채널 · GSC AIO · 기준선 스팟체크 |
| F | 기술 위생 | 5 | 이미지 alt · CMS 최신성 · 페이지 무게 · 모바일 |
## 항목 목록
| ID | 항목 | 배점 | 근거 | 통제 | 채점 | 3점 기준 |
|---|---|---|---|---|---|---|
| A1 | 검색용 AI 봇 허용 | 5 | 실측 | 직접 | 자동 | 검색봇 전부 허용 + 200 응답 |
| A2 | CDN/WAF AI 차단 없음 | 3 | 실측 | 직접 | 자동 | CDN 차단 없음 확인 |
| A3 | 서버 렌더링 본문 | 3 | 실측 | 직접 | 자동 | JS 미실행 텍스트 80% 이상 |
| A4 | HTTPS·리다이렉트 정합 | 2 | 실측 | 직접 | 자동 | 전부 301로 정본 수렴 |
| A5 | sitemap.xml 제공 | 3 | 실측 | 직접 | 자동 | 선언·200·lastmod 최신 |
| A6 | Bing 색인 (ChatGPT 경로) | 3 | 벤더 | 직접 | 수동 | 색인 + IndexNow |
| A7 | 응답 속도·크롤 예산 | 1 | 추정 | 직접 | 자동 | TTFB < 0.5s, HTML < 300KB |
| B1 | JSON-LD 문법 유효성 | 3 | 실측 | 직접 | 자동 | 전부 유효 + @graph 연결 |
| B2 | MedicalClinic / LocalBusiness | 5 | 학술 | 직접 | 자동 | + openingHours·medicalSpecialty·geo |
| B3 | Physician 스키마 | 3 | 학술 | 직접 | 자동 | 자격·소속·sameAs 포함 |
| B4 | NAP 사이트 내 일관성 | 3 | 실측 | 직접 | 자동 | 단일 표기 |
| B5 | sameAs 연결 | 2 | 학술 | 직접 | 자동 | 5개 이상 (SNS+버티컬+지도) |
| B6 | canonical·중복 제거 | 2 | 실측 | 직접 | 자동 | 있음 + 파라미터 정리 |
| B7 | 다국어 hreflang | 2 | 벤더 | 직접 | 자동 | 3개 이상 연결 |
| C1 | 정의문형 첫 문단 | 4 | 학술 | 직접 | 반자동 | 전 핵심 페이지 정의문 |
| C2 | FAQ 섹션 | 4 | 학술 | 직접 | 자동 | FAQ + FAQPage 스키마 + 시술별 |
| C3 | 제목 계층 의미성 | 3 | 실측 | 직접 | 자동 | H1 1개 서술형, H2 논리 계층 |
| C4 | 구체 수치 (가격·시간·회복) | 4 | 학술 | 직접 | 반자동 | 시술별 가격대·시간·회복 표기 |
| C5 | 표·비교 구조 | 2 | 벤더 | 직접 | 자동 | HTML 표 다수 |
| C6 | 신선도 신호 | 3 | 학술 | 직접 | 자동 | 3개월 이내 + dateModified |
| C7 | 의료진 E-E-A-T | 3 | 학술 | 직접 | 반자동 | + 논문·학회·수상 + Physician 스키마 |
| C8 | 패시지 자기완결성 | 2 | 학술 | 직접 | 수동 | 80% 이상 |
| D1 | 강남언니 프로필 | 6 | 실측 | 직접 | 수동 | + 의료진·가격 갱신 3개월 이내 |
| D2 | Google 비즈니스 프로필 | 4 | 실측 | 직접 | 수동 | 소유·리뷰 응답·게시물 활성 |
| D3 | 오픈웹 언급 | 4 | 학술 | 영향 | 반자동 | 다수 오픈 도메인 일관 언급 |
| D4 | 표면 간 NAP 완전 일치 | 3 | 실측 | 직접 | 수동 | 완전 일치 |
| D5 | 네이버 플레이스 (AI 브리핑) | 2 | 실측 | 직접 | 반자동 | 연결·리뷰·소식 활성 |
| D6 | Wikidata/위키백과 | 1 | 추정 | 영향 | 자동 | 항목 + 공식 사이트 연결 |
| E1 | AI 크롤러 로그 (IP 검증) | 3 | 실측 | 직접 | 수동 | IP 검증 히트 집계 |
| E2 | GA4 AI 유입 채널 | 3 | 실측 | 직접 | 수동 | 커스텀 채널 (Perplexity 포함) |
| E3 | GSC AI Overviews | 2 | 벤더 | 직접 | 수동 | 월간 추적 |
| E4 | 기준선 스팟체크 | 2 | 학술 | 직접 | 수동 | 분기 반복 측정 |
| F1 | 이미지 대체텍스트 | 2 | 실측 | 직접 | 자동 | 5% 미만 누락 |
| F2 | CMS 보안·최신성 | 1 | 추정 | 직접 | 자동 | 최신 |
| F3 | 페이지 무게 | 1 | 추정 | 직접 | 자동 | < 300KB |
| F4 | 모바일 대응 | 1 | 실측 | 직접 | 자동 | 반응형 + CWV 양호 |
각 항목의 level 0~2 라벨과 `howToCheck`(자동화 구현 스펙)는 `aeoGeoRubric.ts`에 있다.
## 자동화 로드맵 (Edge Function `discover-aeo-geo`)
| 단계 | 자동 채점 가능 항목 | 도구 |
|---|---|---|
| 1 | A1–A5, A7, B1, B4, B6, B7, C2(탐지), C3, C5, C6, F1–F4 | robots 파싱 + curl UA 테스트 + HTML 파서 (crawler_audit.py 로직 이식) |
| 2 | B2, B3, C1, C4, C7, C8 | Perplexity/Claude에 HTML 텍스트 투입 → 구조화 판정 |
| 3 | D3, D6 | 검색 그라운딩 도메인 집계, Wikidata API |
| 수동 | A6, D1, D2, D4, E1–E4 | 오너 계정 접근 또는 담당자 확인 → 입력 슬롯 |
## 하지 않을 것 (리포트에 항상 명시)
- llms.txt 납품 · "스키마 넣으면 인용됩니다" · 단발 조회 순위 보고 · 답변엔진 UI 자동 조회(약관 위반) · 네이버 블로그 증량(글로벌 AI 기여도 0)

37
src/components/Badge.tsx Normal file
View File

@ -0,0 +1,37 @@
/**
* Badge — 가격표 등에서 쓰는 강조 배지.
* PricingPage가 import하던 컴포넌트가 저장소에 누락되어 빌드가 깨져 있어 복원 (2026-08-28).
*/
type BadgeVariant = 'popular' | 'new' | 'recommended';
type BadgeSize = 'sm' | 'md';
const LABEL: Record<BadgeVariant, string> = {
popular: 'Most Popular',
new: 'New',
recommended: 'Recommended',
};
const SIZE: Record<BadgeSize, string> = {
sm: 'text-[10px] px-2.5 py-0.5',
md: 'text-xs px-3 py-1',
};
export default function Badge({
variant = 'popular',
size = 'md',
label,
className = '',
}: {
variant?: BadgeVariant;
size?: BadgeSize;
label?: string;
className?: string;
}) {
return (
<span
className={`inline-flex items-center rounded-full font-semibold tracking-wide text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] ${SIZE[size]} ${className}`}
>
{label ?? LABEL[variant]}
</span>
);
}

View File

@ -3,6 +3,8 @@ type SeverityLevel = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown';
interface SeverityBadgeProps { interface SeverityBadgeProps {
severity: SeverityLevel; severity: SeverityLevel;
label?: string; label?: string;
/** 크기·굵기 오버라이드 (기본 text-xs font-medium) */
className?: string;
} }
const config: Record<SeverityLevel, { className: string; defaultLabel: string }> = { const config: Record<SeverityLevel, { className: string; defaultLabel: string }> = {
@ -28,12 +30,12 @@ const config: Record<SeverityLevel, { className: string; defaultLabel: string }>
}, },
}; };
export function SeverityBadge({ severity, label }: SeverityBadgeProps) { export function SeverityBadge({ severity, label, className: extra = '' }: SeverityBadgeProps) {
const { className, defaultLabel } = config[severity]; const { className, defaultLabel } = config[severity];
return ( return (
<span <span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${className}`} className={`inline-flex items-center rounded-full border ${extra || 'text-xs font-medium px-3 py-1'} ${className}`}
> >
{label ?? defaultLabel} {label ?? defaultLabel}
</span> </span>

531
src/data/aeoGeoRubric.ts Normal file
View File

@ -0,0 +1,531 @@
/**
* INFINITH AI Discovery AEO/GEO 채점 기준표 v1.0
*
* 근거 기반: ado2-aeo-geo 스킬 (2026-08-26 한국 실측 · Princeton GEO · C-SEO Bench)
*
* 배점 철학
* - "차단되면 절대 인용될 수 없다"는 논쟁의 여지가 없다 → A(접근성)·B(엔티티) 배점이 높다
* - GEO 콘텐츠 전술의 효과는 학술적으로 논쟁 중 → C는 배점이 크지만 "좋은 콘텐츠 원칙"으로만 설명한다
* - llms.txt는 항목에서 제외한다 (Google 공식 효과 0, 채택률 0.13%)
* - 병의원 업종은 강남언니(ai-input=yes)가 AI 인용을 장악 → D(표면)에서 최고 배점
*/
import type { Rubric } 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: Rubric = {
version: '1.0',
updatedAt: '2026-08-28',
categories: [
{
id: 'access',
code: 'A',
name: 'AI 크롤러 접근성',
nameEn: 'Access & Crawlability',
description:
'AI 검색엔진이 사이트를 읽을 수 있는가. 크롤러 접근이 막히면 나머지 항목은 전부 무효다.',
weight: 20,
},
{
id: 'entity',
code: 'B',
name: '엔티티 식별·구조화',
nameEn: 'Entity & Structured Data',
description:
'AI가 "이 병원이 누구인지"를 기계적으로 확정할 수 있는가. NAP 일관성과 스키마 위생.',
weight: 20,
},
{
id: 'content',
code: 'C',
name: '답변 인용 가능 콘텐츠',
nameEn: 'Answer-Ready Content',
description:
'패시지 단위로 잘라내도 자기완결적인 사실 덩어리가 있는가. 정의문·FAQ·수치·표·의료진 근거.',
weight: 25,
},
{
id: 'surface',
code: 'D',
name: 'AI 인용 표면 확보',
nameEn: 'Off-site Surfaces',
description:
'실제로 AI가 인용하는 외부 표면(강남언니·GBP·오픈웹)에 존재하는가. 네이버 자산은 글로벌 AI에 기여도 0.',
weight: 20,
},
{
id: 'measure',
code: 'E',
name: 'AI 가시성 측정 인프라',
nameEn: 'Measurement',
description:
'개선을 증명할 수 있는가. 소유 신호(크롤러 로그·GA4 AI 유입) 계측 여부.',
weight: 10,
},
{
id: 'hygiene',
code: 'F',
name: '기술 위생',
nameEn: 'Technical Hygiene',
description: '이미지 대체텍스트·CMS 보안·페이지 무게·모바일. 보조 지표.',
weight: 5,
},
],
criteria: [
// ───────────── A. 접근성 (20) ─────────────
{
id: 'A1',
category: 'access',
name: '검색용 AI 봇 허용',
rationale:
'OAI-SearchBot·PerplexityBot·Claude-SearchBot·bingbot·Googlebot이 막히면 인용 자체가 불가능. 학습봇(GPTBot·ClaudeBot)과 구분해 평가.',
weight: 5,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'robots.txt 파싱 + 각 UA로 실제 GET 200 확인 (crawler_audit.py)',
levels: L('검색봇 2개 이상 차단', '검색봇 1개 차단', '모두 허용, 학습봇 일부 차단', '검색봇 전부 허용 + 200 응답'),
},
{
id: 'A2',
category: 'access',
name: 'CDN/WAF AI 차단 없음',
rationale: 'Cloudflare는 2025-07부터 신규 도메인에 AI 크롤러 기본 차단. 업체가 인지하지 못한 채 AI 검색에서 사라지는 주원인.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: '응답 헤더(server/cf-ray) + AI UA 요청 시 403/challenge 여부',
levels: L('AI UA 403/챌린지', '일부 UA 차단', 'CDN 사용, 차단 없음', 'CDN 차단 없음 확인'),
},
{
id: 'A3',
category: 'access',
name: '서버 렌더링 본문',
rationale: 'AI 크롤러는 JS를 실행하지 않거나 지연시킨다. 초기 HTML에 본문이 없으면 빈 페이지로 읽힌다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'JS 미실행 HTML의 텍스트 길이 / 렌더 후 텍스트 길이 비율',
levels: L('본문 전무 (SPA 빈 셸)', '30% 미만', '30~80%', '80% 이상 (SSR/정적)'),
},
{
id: 'A4',
category: 'access',
name: 'HTTPS·리다이렉트 정합',
rationale: 'http/www 변형이 하나의 정본 URL로 301 수렴해야 엔티티가 분산되지 않는다.',
weight: 2,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'http://, http://www, https:// 4변형 → 최종 URL 동일 여부',
levels: L('HTTPS 없음', '변형 간 200 중복', '일부 302', '전부 301로 정본 수렴'),
},
{
id: 'A5',
category: 'access',
name: 'sitemap.xml 제공',
rationale: '크롤러 발견 경로. robots.txt에 선언되어야 한다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'robots.txt Sitemap: 선언 + 200 + lastmod 존재',
levels: L('없음', '있으나 robots 미선언', '선언·200', '선언·200·lastmod 최신'),
},
{
id: 'A6',
category: 'access',
name: 'Bing 색인 (ChatGPT 검색 경로)',
rationale: 'ChatGPT 검색은 Bing 인덱스에 상당 부분 의존. Bing Webmaster + IndexNow 등록 여부.',
weight: 3,
evidenceGrade: 'vendor',
control: 'direct',
method: 'manual',
howToCheck: 'Bing Webmaster Tools 등록 확인 (오너 접근 필요) 또는 site: 검색 결과 수',
levels: L('Bing 미색인', '일부 색인', '색인됨', '색인 + IndexNow 연동'),
},
{
id: 'A7',
category: 'access',
name: '응답 속도·크롤 예산',
rationale: 'TTFB가 느리거나 HTML이 비대하면 크롤러가 페이지를 덜 가져간다.',
weight: 1,
evidenceGrade: 'estimate',
control: 'direct',
method: 'auto',
howToCheck: 'TTFB < 0.5s, HTML < 300KB',
levels: L('TTFB > 2s', 'TTFB > 1s 또는 HTML > 1MB', 'TTFB < 0.5s, HTML > 300KB', 'TTFB < 0.5s, HTML < 300KB'),
},
// ───────────── B. 엔티티 (20) ─────────────
{
id: 'B1',
category: 'entity',
name: 'JSON-LD 문법 유효성',
rationale: '문법이 깨진 JSON-LD는 파서가 통째로 버린다. 있는데 무효인 것은 없는 것과 같다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: '모든 ld+json 블록 JSON.parse 성공 여부',
levels: L('전부 무효 또는 없음', '일부 무효', '전부 유효', '전부 유효 + @graph 연결'),
},
{
id: 'B2',
category: 'entity',
name: 'MedicalClinic / LocalBusiness 스키마',
rationale: '엔티티 식별을 돕는 위생 조치. 인과 증거는 없으나 name·address·telephone·openingHours·medicalSpecialty를 기계가 읽게 한다.',
weight: 5,
evidenceGrade: 'academic',
control: 'direct',
method: 'auto',
howToCheck: '@type MedicalClinic|MedicalBusiness|LocalBusiness 존재 + 필수 속성 4개',
levels: L('없음', 'Organization만', 'MedicalClinic + 주소·전화', '+ openingHours·medicalSpecialty·geo'),
},
{
id: 'B3',
category: 'entity',
name: 'Physician 스키마 (의료진)',
rationale: '"누가 수술하나"는 병의원 질의의 핵심. 의사 이름·전문의 자격을 구조화하면 E-E-A-T 엔티티가 된다.',
weight: 3,
evidenceGrade: 'academic',
control: 'direct',
method: 'auto',
howToCheck: '@type Physician + name·medicalSpecialty·worksFor',
levels: L('없음', '이름만', '전문분야 포함', '자격·소속·sameAs 포함'),
},
{
id: 'B4',
category: 'entity',
name: 'NAP 사이트 내 일관성',
rationale: '같은 사이트 안에서 주소 표기가 다르면 외부 표면과 대조할 기준이 없다. 한 글자까지 동일해야 한다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: '주소·전화 정규식 추출 → 고유 표기 수',
levels: L('주소/전화 미표기', '3개 이상 표기 변형', '2개 변형', '단일 표기'),
},
{
id: 'B5',
category: 'entity',
name: 'sameAs 연결',
rationale: 'Organization.sameAs로 YouTube·Instagram·강남언니·GBP를 연결해야 AI가 흩어진 언급을 한 엔티티로 묶는다.',
weight: 2,
evidenceGrade: 'academic',
control: 'direct',
method: 'auto',
howToCheck: 'Organization.sameAs 배열 길이',
levels: L('비어 있음', '1~2개', '3~4개', '5개 이상 (SNS + 버티컬 + 지도)'),
},
{
id: 'B6',
category: 'entity',
name: 'canonical·중복 제거',
rationale: '같은 내용이 여러 URL이면 인용 신호가 분산된다.',
weight: 2,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'rel=canonical 존재 + self-referencing',
levels: L('없음', '있으나 불일치', '있음', '있음 + 파라미터 정리'),
},
{
id: 'B7',
category: 'entity',
name: '다국어 hreflang',
rationale: '외국인 환자 질의(영·중·일)는 별도 엔진 표면. 언어 버전이 있다면 hreflang으로 연결해야 한다.',
weight: 2,
evidenceGrade: 'vendor',
control: 'direct',
method: 'auto',
howToCheck: 'link hreflang 태그 수',
levels: L('다국어 페이지 있으나 미연결', '단일 언어', '2개 언어 연결', '3개 이상 연결'),
},
// ───────────── C. 콘텐츠 (25) ─────────────
{
id: 'C1',
category: 'content',
name: '정의문형 첫 문단',
rationale: 'Google AI Mode의 query fan-out은 패시지 단위로 검색한다. "○○은 강남 신논현에 있는 가슴성형 전문 성형외과다"처럼 답을 먼저 써야 청크가 자기완결적이 된다.',
weight: 4,
evidenceGrade: 'academic',
control: 'direct',
method: 'semi',
howToCheck: '메인·시술 페이지 첫 200자에 [상호+지역+전문분야] 정의문 존재',
levels: L('없음 (슬로건·이미지만)', '슬로건형', '일부 페이지에 정의문', '전 핵심 페이지 정의문'),
},
{
id: 'C2',
category: 'content',
name: 'FAQ 섹션',
rationale: '실제 질문 형태 그대로의 H2/H3 + 답변은 답변엔진이 가장 잘 가져가는 구조. FAQPage 스키마와 결합.',
weight: 4,
evidenceGrade: 'academic',
control: 'direct',
method: 'auto',
howToCheck: 'FAQ/Q&A 섹션 탐지 + FAQPage 스키마',
levels: L('없음', '게시판형 Q&A만', 'FAQ 섹션 존재', 'FAQ + FAQPage 스키마 + 시술별'),
},
{
id: 'C3',
category: 'content',
name: '제목 계층 의미성',
rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "VIEW SELFIE" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'H1 개수 = 1, H1/H2 텍스트가 명사구 서술형인지, 중복 H2 비율',
levels: L('H1 없음 또는 5개 이상', 'H1 다수·라벨형', 'H1 1개, H2 일부 라벨형', 'H1 1개 서술형, H2 논리 계층'),
},
{
id: 'C4',
category: 'content',
name: '구체 수치 (가격·시간·회복)',
rationale: '가격대·수술시간·회복기간·재수술률 같은 수치는 인용되는 콘텐츠의 공통 특징. (Princeton GEO 정합, 단 C-SEO Bench 반박 있음 → 효과 보장 아님)',
weight: 4,
evidenceGrade: 'academic',
control: 'direct',
method: 'semi',
howToCheck: '시술 페이지에서 원/분/일/% 단위 수치 밀도',
levels: L('수치 없음', '마케팅 문구 속 수치만', '시술별 일부 수치', '시술별 가격대·시간·회복 표기'),
},
{
id: 'C5',
category: 'content',
name: '표·비교 구조',
rationale: 'AI가 인용하는 콘텐츠는 구조화·표 중심 (인블로그 조사). 보형물 비교표·시술 비교표가 대표 예.',
weight: 2,
evidenceGrade: 'vendor',
control: 'direct',
method: 'auto',
howToCheck: '<table> 또는 정의 리스트 존재 (이미지 표 제외)',
levels: L('없음', '이미지로만 된 표', 'HTML 표 1~2개', 'HTML 표 다수'),
},
{
id: 'C6',
category: 'content',
name: '신선도 신호',
rationale: 'dateModified·최종 수정일 명시. 1년 이상 갱신 없는 사이트는 "폐업했나?" 오정보 위험.',
weight: 3,
evidenceGrade: 'academic',
control: 'direct',
method: 'auto',
howToCheck: 'sitemap lastmod 최신값, article:modified_time, 본문 갱신일',
levels: L('2년 이상 갱신 없음', '1년 이상', '6개월 이내', '3개월 이내 + dateModified 표기'),
},
{
id: 'C7',
category: 'content',
name: '의료진 E-E-A-T',
rationale: '전문의 자격·경력·학회·수상이 텍스트로 존재해야 AI가 "믿을 만한 출처"로 판단할 근거가 생긴다. 이미지 안의 글자는 안 읽힌다.',
weight: 3,
evidenceGrade: 'academic',
control: 'direct',
method: 'semi',
howToCheck: '의료진 페이지 텍스트에 전문의·경력·학회 명시 여부',
levels: L('의료진 정보 없음', '이름·사진만', '전문의·경력 텍스트', '+ 논문·학회·수상 + Physician 스키마'),
},
{
id: 'C8',
category: 'content',
name: '패시지 자기완결성',
rationale: '한 단락만 떼어내도 "누가·어디서·무엇을"이 들어 있어야 인용된다. 대명사·"저희"로 시작하는 단락은 맥락을 잃는다.',
weight: 2,
evidenceGrade: 'academic',
control: 'direct',
method: 'manual',
howToCheck: '핵심 페이지 단락 샘플 10개 중 상호·시술명이 포함된 비율',
levels: L('0~20%', '20~50%', '50~80%', '80% 이상'),
},
// ───────────── D. 표면 (20) ─────────────
{
id: 'D1',
category: 'surface',
name: '강남언니 프로필',
rationale: '2026-08 실측: 병의원 질의는 강남언니(ai-input=yes 명시)·닥터나우·마이닥터가 AI 인용을 100% 장악. 여기 없으면 AI에겐 없는 병원.',
weight: 6,
evidenceGrade: 'measured',
control: 'direct',
method: 'manual',
howToCheck: '강남언니 병원 페이지 존재·시술 등록 수·후기 수·최근 갱신',
levels: L('미등록', '등록, 시술 3개 미만', '시술 다수·후기 활성', '+ 의료진·가격 갱신 3개월 이내'),
},
{
id: 'D2',
category: 'surface',
name: 'Google 비즈니스 프로필',
rationale: 'Gemini·Google AI Mode의 구조화 데이터 원천. 국내 성형외과 등록률이 낮아 선점 여지가 크다.',
weight: 4,
evidenceGrade: 'measured',
control: 'direct',
method: 'manual',
howToCheck: 'Google Maps 검색 → 소유권 확인·카테고리·사진·리뷰·NAP 일치',
levels: L('없음', '미소유 자동 생성', '소유·기본 정보', '소유·리뷰 응답·게시물 활성'),
},
{
id: 'D3',
category: 'surface',
name: '오픈웹 언급 (티스토리·유튜브·언론)',
rationale: 'Ahrefs 7.5만 브랜드: 웹 언급 상관 r=0.664 (백링크 0.218). 네이버 블로그·카페는 AI 차단이라 제외.',
weight: 4,
evidenceGrade: 'academic',
control: 'influence',
method: 'semi',
howToCheck: '검색 그라운딩으로 상호 언급 도메인 집계 (naver.com 제외)',
levels: L('자체 사이트 외 없음', '유튜브만', '유튜브 + 언론/티스토리', '다수 오픈 도메인에서 일관된 언급'),
},
{
id: 'D4',
category: 'surface',
name: '표면 간 NAP 완전 일치',
rationale: '홈페이지·강남언니·GBP·플레이스의 주소·전화가 한 글자라도 다르면 답변엔진이 동일 업체로 묶지 못한다.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'manual',
howToCheck: '표면별 NAP 표를 만들어 대조',
levels: L('전화번호 상이', '주소 표기 상이', '경미한 표기 차이', '완전 일치'),
},
{
id: 'D5',
category: 'surface',
name: '네이버 플레이스 (AI 브리핑 전용)',
rationale: '글로벌 AI에는 기여도 0. 단 네이버 AI 브리핑(검색의 20%→40%)은 별도 게임이며 플레이스↔홈페이지 연결이 중요.',
weight: 2,
evidenceGrade: 'measured',
control: 'direct',
method: 'semi',
howToCheck: '플레이스 존재·홈페이지 링크·리뷰 수',
levels: L('없음', '있음, 홈페이지 미연결', '연결·리뷰 활성', '연결·리뷰·소식 활성'),
},
{
id: 'D6',
category: 'surface',
name: '위키데이터/위키백과 엔티티',
rationale: 'LLM의 엔티티 앵커. 대형 병원만 해당하나 있으면 확정적.',
weight: 1,
evidenceGrade: 'estimate',
control: 'influence',
method: 'auto',
howToCheck: 'Wikidata API 상호 검색',
levels: L('없음', '언급만', '항목 존재', '항목 + 공식 사이트 연결'),
},
// ───────────── E. 측정 (10) ─────────────
{
id: 'E1',
category: 'measure',
name: 'AI 크롤러 로그 (IP 검증)',
rationale: 'AI가 우리를 읽었는가(선행 지표). 크롤러 UA의 최대 81.8%가 위조 → IP 검증 히트만 인정.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'manual',
howToCheck: 'Cloudflare AI Crawl Control 또는 서버로그 + bot_ip_verify.py',
levels: L('로그 접근 불가', '로그 있음, 미분석', 'UA 기준 집계', 'IP 검증 히트 집계'),
},
{
id: 'E2',
category: 'measure',
name: 'GA4 AI 유입 채널',
rationale: 'AI에서 사람이 왔는가(후행·매출 귀속). chatgpt.com·perplexity.ai 등 커스텀 채널그룹. 35~70%가 Direct로 유실되므로 하한값.',
weight: 3,
evidenceGrade: 'measured',
control: 'direct',
method: 'manual',
howToCheck: 'GA4 커스텀 채널그룹 존재 여부 (오너 접근 필요)',
levels: L('GA4 없음', 'GA4 있음, AI 채널 없음', '기본 AI Assistant 채널', '커스텀 채널 (Perplexity 포함)'),
},
{
id: 'E3',
category: 'measure',
name: 'GSC AI Overviews 노출',
rationale: 'Google AI 표면 노출 (2026-06~ Search Console UI 전용).',
weight: 2,
evidenceGrade: 'vendor',
control: 'direct',
method: 'manual',
howToCheck: 'GSC 등록 + AI Overviews 필터 확인',
levels: L('GSC 미등록', '등록만', '데이터 확인', '월간 추적'),
},
{
id: 'E4',
category: 'measure',
name: '기준선 스팟체크',
rationale: '질의 10개 × 5~7회 반복 수동 측정 → 언급률+신뢰구간. 단발 조회는 무효(분산 10~34%).',
weight: 2,
evidenceGrade: 'academic',
control: 'direct',
method: 'manual',
howToCheck: '04_baseline 진단 리포트 존재 여부',
levels: L('없음', '단발 조회만', '반복 측정 1회', '분기 반복 측정'),
},
// ───────────── F. 위생 (5) ─────────────
{
id: 'F1',
category: 'hygiene',
name: '이미지 대체텍스트',
rationale: '성형외과 사이트는 정보의 대부분이 이미지 안 글자. alt가 없으면 AI에게 그 정보는 없다.',
weight: 2,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'alt 누락 이미지 비율',
levels: L('50% 이상 누락', '20~50% 누락', '5~20% 누락', '5% 미만 누락'),
},
{
id: 'F2',
category: 'hygiene',
name: 'CMS 보안·최신성',
rationale: '지원 종료 CMS는 해킹 시 스팸 페이지가 주입되어 AI가 엉뚱한 내용을 인용할 위험.',
weight: 1,
evidenceGrade: 'estimate',
control: 'direct',
method: 'auto',
howToCheck: 'meta generator 버전 vs 최신 안정 버전',
levels: L('3년 이상 구버전', '1~3년', '1년 이내', '최신'),
},
{
id: 'F3',
category: 'hygiene',
name: '페이지 무게',
rationale: 'HTML 크기가 크면 크롤 예산 소모·요약 품질 저하.',
weight: 1,
evidenceGrade: 'estimate',
control: 'direct',
method: 'auto',
howToCheck: '초기 HTML 바이트',
levels: L('> 2MB', '> 800KB', '300~800KB', '< 300KB'),
},
{
id: 'F4',
category: 'hygiene',
name: '모바일 대응',
rationale: '모바일 인앱 브라우저 유입(ChatGPT 앱)이 주 경로.',
weight: 1,
evidenceGrade: 'measured',
control: 'direct',
method: 'auto',
howToCheck: 'viewport meta + 반응형',
levels: L('viewport 없음', '별도 m. 도메인', '반응형', '반응형 + CWV 양호'),
},
],
};
export const getCriterion = (id: string) =>
AEO_GEO_RUBRIC.criteria.find((c) => c.id === id);
export const getCategory = (id: string) =>
AEO_GEO_RUBRIC.categories.find((c) => c.id === id);

View File

@ -0,0 +1,159 @@
/**
* 뷰성형외과 (viewclinic.com) AEO/GEO 진단 결과
* 실측일: 2026-08-28 · 도구: crawler_audit.py + curl + HTML 파서
* 미검증 항목은 'unverified'. 오너 접근(GA4·GSC·Bing) 또는 수동 확인이 필요하다.
*/
import type { DiscoveryResult } from '../types/discovery';
export const DISCOVERY_VIEWCLINIC: DiscoveryResult = {
id: 'viewclinic',
clinicName: '뷰성형외과',
url: 'https://www.viewclinic.com',
industry: '성형외과 (강남 신논현)',
auditedAt: '2026-08-28',
rubricVersion: '1.0',
conditions: [
'실측 대상: 메인 페이지 + /breast/main/ (가슴성형) 2개 URL, JS 미실행 HTML 기준',
'크롤러 접근성: robots.txt 파싱 + 12개 AI 봇 UA 실제 GET 요청',
'외부 표면(강남언니·GBP·NAP 대조)과 측정 인프라(GA4·GSC)는 오너 접근 없이 확인 불가 → 미검증 처리, 분모 제외',
'답변엔진 언급률은 측정하지 않았다 (단발 조회는 무효, 반복 측정은 별도 기준선 단계에서 수행)',
],
results: [
// A. 접근성
{ criterionId: 'A1', level: 3, evidence: '12개 AI 봇 전부 허용. OAI-SearchBot·PerplexityBot·GPTBot UA로 GET → 전부 200', source: 'robots.txt: User-agent: * / Disallow: /wp-admin/' },
{ criterionId: 'A2', level: 3, evidence: 'Server: nginx 직접 응답, Cloudflare 헤더 없음, AI UA 챌린지 없음' },
{ criterionId: 'A3', level: 3, evidence: 'WordPress 서버 렌더링. JS 미실행 HTML에서 본문 텍스트 10,757자 추출' },
{ criterionId: 'A4', level: 3, evidence: 'http://viewclinic.com → 301 → https://www.viewclinic.com/ 정본 수렴' },
{ criterionId: 'A5', level: 2, evidence: 'robots.txt에 sitemap 선언, 200 응답, AIOSEO 3.7.1 동적 생성. 페이지 249개 + kboard 11개. 단 post lastmod 최신이 2024-05-31' },
{ criterionId: 'A6', level: 'unverified', evidence: 'Bing Webmaster Tools 등록 여부는 오너 계정 접근 필요' },
{ criterionId: 'A7', level: 2, evidence: 'TTFB 0.135s (양호) / 초기 HTML 838KB (비대). 시술 페이지 862KB' },
// B. 엔티티
{ criterionId: 'B1', level: 1, evidence: 'JSON-LD 2블록 중 ItemList 블록이 문법 오류(쉼표 누락, line 11)로 파싱 실패 → 시술 목록 구조화 데이터 전체 무효', source: 'Expecting \',\' delimiter: line 11 column 7' },
{ criterionId: 'B2', level: 1, evidence: 'Organization + WebSite + WebPage만 존재. MedicalClinic/LocalBusiness 없음 → 주소·전화·진료시간이 구조화되어 있지 않음' },
{ criterionId: 'B3', level: 0, evidence: 'Physician 스키마 없음. 의료진 정보는 HTML 텍스트·이미지로만 존재' },
{ criterionId: 'B4', level: 2, evidence: '주소 2가지 표기 혼재: "서울 강남구 봉은사로 107 뷰성형외과 빌딩" / "서울특별시 강남구 봉은사로 107, 뷰성형외과빌딩". 전화 02-539-1177 + 휴대폰 2개 혼재' },
{ criterionId: 'B5', level: 0, evidence: 'Organization.sameAs: [] (빈 배열). 푸터에 YouTube·Instagram·Facebook·네이버블로그 링크가 있으나 스키마에 미연결' },
{ criterionId: 'B6', level: 2, evidence: 'rel=canonical self-referencing 존재' },
{ criterionId: 'B7', level: 1, evidence: 'hreflang 없음, lang=ko-KR 단일 언어. 외국인 환자 대상 언어 페이지 미확인' },
// C. 콘텐츠
{ criterionId: 'C1', level: 0, evidence: '메인 첫 화면은 슬라이더·"VIEW SELFIE" 등 라벨. "뷰성형외과는 …에 있는 … 병원입니다" 형태의 정의문 없음. 시술 페이지 첫 텍스트는 오시는길·상담폼' },
{ criterionId: 'C2', level: 0, evidence: 'FAQ·자주 묻는 질문·Q&A 섹션 탐지 안 됨 (메인·가슴성형 페이지)' },
{ criterionId: 'C3', level: 0, evidence: 'H1 5개: "VIEW SELFIE / VIEW EVENT / VIEW SHORTS / VIEW FILM / VIEW SPECIALITY". 전부 영문 라벨. H2는 같은 문구가 2회씩 중복(모바일/PC 분기). 시술 페이지 H1 없음' },
{ criterionId: 'C4', level: 1, evidence: '메타 설명에 "가슴보형물사용량1위" 등 주장형 문구. 가슴성형 페이지에 가격/비용 단어 8회이나 표기된 가격대·수술시간·회복기간 표 없음' },
{ criterionId: 'C5', level: 0, evidence: 'HTML <table> 없음. 비교 정보는 이미지로 추정' },
{ criterionId: 'C6', level: 1, evidence: 'article:modified_time·dateModified 없음. post-sitemap 최신 lastmod 2024-05-31 (15개월 전). 단 H3 콘텐츠(유튜브 제목)는 최근 갱신 흔적' },
{ criterionId: 'C7', level: 2, evidence: '원장·전문의·의료진 언급 17회, 모티바 글로벌 KOL·보건복지부장관표창·갈더마 방문 등 권위 신호 존재. 단 구조화 안 됨' },
{ criterionId: 'C8', level: 1, evidence: 'H2 "철저한 정밀 검진 / ONE_STOP 시스템 / 1:1 전담 마취시스템". 단독으로 떼면 어느 병원의 무엇인지 알 수 없음' },
// D. 표면
{ criterionId: 'D1', level: 'unverified', evidence: '강남언니 프로필 존재·시술 수·후기 수는 수동 확인 필요 (P0 확인 항목)' },
{ criterionId: 'D2', level: 'unverified', evidence: 'Google 비즈니스 프로필 소유 여부 수동 확인 필요' },
{ criterionId: 'D3', level: 2, evidence: '자체 YouTube 채널 활발(VIEW SHORTS/FILM, 최신 영상 제목 다수). 티스토리·언론 언급은 미집계' },
{ criterionId: 'D4', level: 'unverified', evidence: '외부 표면 NAP 대조표 미작성' },
{ criterionId: 'D5', level: 2, evidence: '네이버 플레이스 존재(리뷰 776건, 2026-04 INFINITH 리포트 기준). 홈페이지 링크 연결 여부 미확인 → 보수적으로 2' },
{ criterionId: 'D6', level: 'unverified', evidence: 'Wikidata 조회 미실시' },
// E. 측정
{ criterionId: 'E1', level: 'unverified', evidence: '서버 로그 접근 필요 (nginx 직접 호스팅, Cloudflare AI Crawl Control 미사용)' },
{ criterionId: 'E2', level: 'unverified', evidence: 'GA4 접근 필요' },
{ criterionId: 'E3', level: 'unverified', evidence: 'GSC 접근 필요' },
{ criterionId: 'E4', level: 0, evidence: '기준선 스팟체크 미실시' },
// F. 위생
{ criterionId: 'F1', level: 1, evidence: '이미지 302개 중 111개(37%) alt 누락 또는 빈 값' },
{ criterionId: 'F2', level: 0, evidence: 'meta generator: WordPress 5.5.3 (2020-10 출시, 6년 전) + WPBakery. 보안 패치 미적용 위험' },
{ criterionId: 'F3', level: 1, evidence: '초기 HTML 838KB (기준 300KB의 2.8배)' },
{ criterionId: 'F4', level: 2, evidence: 'viewport meta 존재, 반응형. CWV 미측정' },
],
keyFindings: [
{
title: '크롤러 접근은 정상이다. AI가 사이트를 읽을 수 있다',
detail: 'robots.txt가 AI 검색봇 12종을 모두 허용하고, Cloudflare 차단도 없으며, WordPress가 본문을 서버에서 렌더링한다. 접근성 카테고리는 상위권. "AI에 안 나오는" 원인은 차단이 아니라 내용에 있다.',
severity: 'good',
},
{
title: '읽어도 "누구인지" 확정할 수 없다',
detail: 'MedicalClinic 스키마가 없고 Organization.sameAs가 비어 있으며, 시술 목록 JSON-LD는 쉼표 하나가 빠져 통째로 무효다. 주소 표기도 사이트 안에서 2가지. AI는 "뷰성형외과"라는 이름과 흩어진 사실을 하나의 엔티티로 묶을 근거를 얻지 못한다.',
severity: 'critical',
},
{
title: '인용할 문장이 없다',
detail: 'H1이 "VIEW SELFIE / VIEW EVENT / VIEW SHORTS…" 5개, FAQ 0개, HTML 표 0개, 정의문 0개. 가슴성형 페이지의 첫 텍스트는 상담 폼이다. 답변엔진이 패시지 단위로 잘라 가져갈 자기완결적 단락이 존재하지 않는다. 권위 신호(KOL·표창)는 있으나 이미지와 슬로건 안에만 있어 텍스트로 읽히지 않는다.',
severity: 'critical',
},
{
title: '15개월간 갱신 신호 없음',
detail: 'post-sitemap의 최신 lastmod가 2024-05-31이고 dateModified 마크업이 없다. 유튜브는 활발하지만 그 신선도가 사이트로 전달되지 않는다. 오래된 사이트는 brand 질의에서 "영업 중인가?" 오정보 위험을 만든다.',
severity: 'warning',
},
{
title: '병의원 AI 인용의 핵심 표면(강남언니·GBP)은 아직 미확인',
detail: '2026-08 실측에서 병의원 AI 인용은 강남언니·닥터나우 등 버티컬이 100% 장악했다. 이 표면의 상태가 실제 노출을 좌우하므로 수동 확인이 P0다. 확인 전까지 분모에서 제외해 점수를 부풀리지 않았다.',
severity: 'warning',
},
],
actions: [
{
priority: 'P0',
title: '깨진 JSON-LD 수정 + MedicalClinic 스키마 삽입',
detail: 'ItemList 블록 쉼표 누락 수정(1시간). Organization → MedicalClinic으로 승격하고 address·telephone·openingHoursSpecification·medicalSpecialty·geo 추가. sameAs에 YouTube·Instagram·Facebook·강남언니·네이버플레이스·GBP URL 연결.',
criterionIds: ['B1', 'B2', 'B5'],
effort: '1일',
},
{
priority: 'P0',
title: '강남언니·Google 비즈니스 프로필 실사',
detail: '프로필 존재·시술 등록 수·후기·가격 갱신일 확인. GBP 소유권 미확인이면 즉시 클레임. 홈페이지·강남언니·GBP·플레이스 4개 표면 NAP 대조표 작성 후 "서울특별시 강남구 봉은사로 107 뷰성형외과빌딩 / 02-539-1177"로 통일.',
criterionIds: ['D1', 'D2', 'D4', 'B4'],
effort: '1주',
},
{
priority: 'P0',
title: '측정 인프라 설치 (개선 증명의 전제)',
detail: 'GA4 커스텀 채널그룹(chatgpt.com·perplexity.ai·gemini.google.com·copilot·claude.ai) 생성. nginx access.log에서 AI 봇 히트 추출 + IP 검증 파이프라인. GSC·Bing Webmaster 등록 확인 + IndexNow. 질의 10개 × 5회 기준선 스팟체크.',
criterionIds: ['E1', 'E2', 'E3', 'E4', 'A6'],
effort: '1주',
},
{
priority: 'P1',
title: '메인·시술 페이지에 정의문 + FAQ 도입',
detail: '각 핵심 페이지 첫 단락에 "뷰성형외과는 서울 강남 신논현역 1분 거리의 가슴성형·안면윤곽 전문 성형외과로, 마취과 전문의가 상주한다"형 정의문. 시술별 FAQ 5~8개(가격대·수술시간·회복기간·보형물 종류·재수술)를 H3 질문형으로 작성 + FAQPage 스키마.',
criterionIds: ['C1', 'C2', 'C4', 'C8'],
effort: '2~4주',
},
{
priority: 'P1',
title: 'H1 재설계 + 이미지 속 정보의 텍스트화',
detail: '페이지당 H1 1개 서술형("뷰성형외과 UIU 가슴성형: 보형물 선택과 안전 시스템"). 모바일/PC 중복 H2 제거. 보형물 비교·시술 비교를 HTML <table>로. 의료진 페이지에 전문의 자격·경력·학회를 텍스트 + Physician 스키마로.',
criterionIds: ['C3', 'C5', 'C7', 'B3'],
effort: '2~4주',
},
{
priority: 'P1',
title: 'WordPress 5.5.3 → 최신 업그레이드',
detail: '6년 된 코어는 보안 위험이자 스팸 주입 시 AI 오인용 위험. 업그레이드 후 alt 누락 111개 보완, HTML 무게 절감(WPBakery 인라인 CSS 정리).',
criterionIds: ['F2', 'F1', 'F3'],
effort: '1주',
},
{
priority: 'P2',
title: '신선도 신호 + 오픈웹 시딩',
detail: 'dateModified 마크업 활성화, 유튜브 영상을 사이트 글로 전사(트랜스크립트 요약)해 매월 갱신. 티스토리·언론 기고로 naver.com 외 언급 도메인 확보. 네이버 블로그는 AI에 안 보이므로 글로벌 AEO 용도로는 쓰지 않는다.',
criterionIds: ['C6', 'D3'],
effort: '지속',
},
],
notDoing: [
{ item: 'llms.txt 제작', reason: 'Google 공식(2026-06) 효과 0, 채택률 0.13%, 서버 로그에 요청 자체가 없음. 위약이다.' },
{ item: '"스키마 넣으면 AI가 인용합니다" 약속', reason: '인과 증거 없음. 스키마는 엔티티 식별을 돕는 위생 조치로만 설명한다.' },
{ item: '단발 조회로 순위·노출 보고', reason: 'LLM 답변 분산 10~34%. 반복 측정 + 신뢰구간으로만 보고한다.' },
{ item: '답변엔진 UI 자동 조회', reason: 'OpenAI·Perplexity 약관 위반. 주력 측정은 오너가 소유한 신호(로그·GA4)로 한다.' },
{ item: '네이버 블로그 증량', reason: 'blog.naver.com robots.txt가 AI 크롤링을 전면 금지. 글로벌 AI 답변 기여도 0. 네이버 AI 브리핑은 별도 트랙.' },
],
};
export const DISCOVERY_RESULTS: Record<string, DiscoveryResult> = {
viewclinic: DISCOVERY_VIEWCLINIC,
};

68
src/lib/discoveryScore.ts Normal file
View File

@ -0,0 +1,68 @@
/**
* AEO/GEO 채점 집계
*
* - level(0~3) → weight × level/3
* - 'unverified' 항목은 분모(possible)에서 제외 → 확인 못 한 것을 감점하지 않는다
* - 전체 점수 = earned / possible × 100 (검증 항목 기준 정규화)
*/
import type {
CategoryScore,
CriterionLevel,
CriterionResult,
DiscoveryResult,
OverallScore,
Rubric,
} from '../types/discovery';
export const levelToPct = (level: CriterionLevel) => (level / 3) * 100;
export const levelToSeverity = (
level: CriterionLevel | 'unverified',
): 'critical' | 'warning' | 'good' | 'excellent' | 'unknown' => {
if (level === 'unverified') return 'unknown';
return (['critical', 'warning', 'good', 'excellent'] as const)[level];
};
export const gradeFromScore = (score: number): OverallScore['grade'] => {
if (score > 80) return 'A';
if (score > 60) return 'B';
if (score > 40) return 'C';
return 'D';
};
export function scoreDiscovery(rubric: Rubric, result: DiscoveryResult): OverallScore {
const byId = new Map<string, CriterionResult>(result.results.map((r) => [r.criterionId, r]));
const categories: CategoryScore[] = rubric.categories.map((category) => {
const criteria = rubric.criteria.filter((c) => c.category === category.id);
let earned = 0;
let possible = 0;
let verifiedCount = 0;
for (const c of criteria) {
const r = byId.get(c.id);
if (!r || r.level === 'unverified') continue;
verifiedCount += 1;
possible += c.weight;
earned += (c.weight * r.level) / 3;
}
return {
category,
earned: round1(earned),
possible,
weight: category.weight,
pct: possible === 0 ? 0 : Math.round((earned / possible) * 100),
verifiedCount,
totalCount: criteria.length,
};
});
const earned = categories.reduce((s, c) => s + c.earned, 0);
const possible = categories.reduce((s, c) => s + c.possible, 0);
const score = possible === 0 ? 0 : Math.round((earned / possible) * 100);
return { score, earned: round1(earned), possible, categories, grade: gradeFromScore(score) };
}
const round1 = (n: number) => Math.round(n * 10) / 10;

View File

@ -15,6 +15,7 @@ import DataValidationPage from './pages/DataValidationPage.tsx';
import ClinicProfilePage from './pages/ClinicProfilePage.tsx'; import ClinicProfilePage from './pages/ClinicProfilePage.tsx';
import ApiDashboardPage from './pages/ApiDashboardPage.tsx'; import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
import LoginPage from './pages/LoginPage.tsx'; import LoginPage from './pages/LoginPage.tsx';
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
import './index.css'; import './index.css';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
@ -36,6 +37,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="data-validation" element={<DataValidationPage />} /> <Route path="data-validation" element={<DataValidationPage />} />
<Route path="clinic/:id" element={<ClinicProfilePage />} /> <Route path="clinic/:id" element={<ClinicProfilePage />} />
<Route path="api-dashboard" element={<ApiDashboardPage />} /> <Route path="api-dashboard" element={<ApiDashboardPage />} />
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -0,0 +1,452 @@
import { useMemo } from 'react';
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 { DISCOVERY_RESULTS } from '../data/discovery_viewclinic';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
ActionPriority,
CategoryId,
CategoryScore,
ControlLevel,
EvidenceGrade,
EvidenceMethod,
RubricCriterion,
} from '../types/discovery';
import { SectionWrapper } from '../components/report/ui/SectionWrapper';
import { ScoreRing } from '../components/report/ui/ScoreRing';
import { SeverityBadge } from '../components/report/ui/SeverityBadge';
import {
ServerFilled,
DatabaseFilled,
FileTextFilled,
ShareFilled,
EyeFilled,
ShieldFilled,
PrismFilled,
CheckFilled,
CrossFilled,
WarningFilled,
} from '../components/icons/FilledIcons';
/* ────────────────────────── 라벨 맵 ────────────────────────── */
const CATEGORY_ICON: Record<CategoryId, ComponentType<{ size?: number; className?: string }>> = {
access: ServerFilled,
entity: DatabaseFilled,
content: FileTextFilled,
surface: ShareFilled,
measure: EyeFilled,
hygiene: ShieldFilled,
};
const EVIDENCE_LABEL: Record<EvidenceGrade, string> = {
measured: '실측',
academic: '학술',
vendor: '벤더',
estimate: '추정',
};
const CONTROL_LABEL: Record<ControlLevel, string> = {
direct: '직접 통제',
influence: '영향 가능',
none: '통제 불가',
};
const METHOD_LABEL: Record<EvidenceMethod, string> = {
auto: '자동',
semi: '반자동',
manual: '수동',
};
const PRIORITY_STYLE: Record<ActionPriority, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
const scoreColor = (pct: number) =>
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
/* ────────────────────────── 페이지 ────────────────────────── */
export default function DiscoveryReportPage() {
const { id } = useParams<{ id: string }>();
const result = id ? DISCOVERY_RESULTS[id] : undefined;
const overall = useMemo(
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
[result],
);
if (!result || !overall) {
return (
<div className="pt-36 pb-24 px-6 text-center">
<p className="text-slate-600">진단 결과를 찾을 수 없습니다.</p>
<Link to="/" className="text-[#6C5CE7] text-base mt-4 inline-block">홈으로</Link>
</div>
);
}
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
return (
<div data-report-content className="[word-break:keep-all]">
{/* ── 1. 헤더 (dark) ── */}
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto grid lg:grid-cols-[1fr_auto] gap-12 items-center">
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
<PrismFilled size={16} className="text-purple-300" />
<span className="text-sm font-semibold tracking-wide text-purple-200">
INFINITH AI Discovery · AEO / GEO Audit v{result.rubricVersion}
</span>
</div>
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
AI Answer Readiness
</span>
</h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="text-purple-200 text-base mb-1">{result.industry}</p>
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="text-purple-300 text-base underline underline-offset-4"
>
{result.url}
</a>
<p className="text-white/60 text-sm mt-4">
실측일 {result.auditedAt} · 검증 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
{' '}{AEO_GEO_RUBRIC.criteria.length} · 미검증 항목은 분모 제외
</p>
</div>
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-3 min-w-[260px]">
<ScoreRing score={overall.score} size={160} label="AI Answer Readiness" />
<div className="flex items-baseline gap-2">
<span className="font-serif text-4xl font-black text-[#0A1128]">{overall.grade}</span>
<span className="text-sm text-slate-500">
{overall.earned} / {overall.possible}점
</span>
</div>
</div>
</div>
{/* 카테고리 바 */}
<div className="relative max-w-7xl mx-auto mt-14 grid md:grid-cols-3 gap-4">
{overall.categories.map((cs, i) => (
<CategoryBar key={cs.category.id} cs={cs} index={i} />
))}
</div>
</section>
{/* ── 2. 핵심 발견 (light) ── */}
<SectionWrapper
id="findings"
title="What AI Sees"
subtitle="AI 답변엔진이 지금 이 병원을 어떻게 읽는지. 진단의 결론"
>
<div className="grid md:grid-cols-2 gap-5">
{result.keyFindings.map((f, i) => (
<motion.div
key={f.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<div className="flex items-start justify-between gap-4 mb-3">
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
<FindingIcon severity={f.severity} />
</div>
<p className="text-base text-slate-600 leading-relaxed">{f.detail}</p>
</motion.div>
))}
</div>
</SectionWrapper>
{/* ── 3. 카테고리별 상세 (dark) ── */}
<SectionWrapper
id="breakdown"
title="Scorecard by Category"
subtitle="36개 항목 · 각 항목의 실측 근거와 근거 등급"
dark
>
<div className="space-y-6">
{overall.categories.map((cs) => {
const Icon = CATEGORY_ICON[cs.category.id];
const criteria = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cs.category.id);
return (
<div
key={cs.category.id}
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 className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center text-[#6C5CE7]">
<Icon size={22} />
</div>
<div>
<p className="text-sm font-semibold text-slate-400 tracking-wide">
{cs.category.code} · {cs.category.nameEn}
</p>
<h3 className="text-xl font-bold">{cs.category.name}</h3>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
<p className="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
</div>
<ScoreRing score={cs.pct} size={64} />
</div>
</div>
<p className="text-base text-slate-500 mb-4">{cs.category.description}</p>
<div className="divide-y divide-slate-100">
{criteria.map((c) => {
const r = resultById.get(c.id);
const level = r?.level ?? 'unverified';
const levelLabel =
level === 'unverified' ? '미검증' : c.levels[level].label;
return (
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
<div>
<span className="text-sm font-semibold text-slate-400">{c.id}</span>
<p className="text-base font-bold">{c.name}</p>
<p className="text-sm text-slate-400 mt-0.5">{c.weight}점</p>
</div>
<div>
<p className="text-base text-slate-700">{r?.evidence}</p>
{r?.source && (
<p className="text-sm text-slate-400 mt-1 font-mono break-all">{r.source}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
<Tag>{CONTROL_LABEL[c.control]}</Tag>
<Tag>{METHOD_LABEL[c.method]}</Tag>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
{level !== 'unverified' && (
<span className="text-sm text-slate-400">
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</SectionWrapper>
{/* ── 4. 실행 계획 (light) ── */}
<SectionWrapper
id="actions"
title="Action Plan"
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
>
<div className="space-y-4">
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
<div key={p}>
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
{p}
</span>
<span className="text-lg font-bold text-[#0A1128]">
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{result.actions.filter((a) => a.priority === p).map((a, i) => (
<motion.div
key={a.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
<span className="text-sm text-slate-400 shrink-0">{a.effort}</span>
</div>
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
<div className="flex flex-wrap gap-1.5">
{a.criterionIds.map((cid) => (
<Tag key={cid}>{cid}</Tag>
))}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</SectionWrapper>
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
<SectionWrapper
id="integrity"
title="What We Don't Sell"
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
dark
>
<div className="grid lg:grid-cols-2 gap-6">
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4">하지 않을 것</h3>
<ul className="space-y-4">
{result.notDoing.map((n) => (
<li key={n.item} className="flex gap-3">
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
<div>
<p className="text-base font-semibold">{n.item}</p>
<p className="text-sm text-slate-500 mt-0.5">{n.reason}</p>
</div>
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4">측정 조건</h3>
<ul className="space-y-3">
{result.conditions.map((c) => (
<li key={c} className="flex gap-3">
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
<p className="text-base text-slate-600">{c}</p>
</li>
))}
</ul>
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
<p className="text-sm text-slate-600 leading-relaxed">
점수는 <strong>검증된 항목만</strong>으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을
확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며,
크롤러 접근·엔티티·콘텐츠 구조라는 <strong>전제조건</strong>의 충족 여부를 진단합니다.
</p>
</div>
</div>
</div>
</SectionWrapper>
{/* ── 6. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"
title="Scoring Rubric"
subtitle={`AEO/GEO 채점 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목 · 100점`}
>
<div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
<table className="w-full text-base min-w-[860px]">
<thead className="bg-[#0A1128] text-white">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">항목</th>
<th className="text-left px-4 py-3 font-semibold w-14">배점</th>
<th className="text-left px-4 py-3 font-semibold w-16">근거</th>
<th className="text-left px-4 py-3 font-semibold w-20">통제</th>
<th className="text-left px-4 py-3 font-semibold w-16">채점</th>
<th className="text-left px-4 py-3 font-semibold">3점 기준</th>
</tr>
</thead>
<tbody>
{AEO_GEO_RUBRIC.categories.map((cat) => (
<RubricRows
key={cat.id}
categoryName={`${cat.code}. ${cat.name} (${cat.weight})`}
criteria={AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id)}
/>
))}
</tbody>
</table>
</div>
</SectionWrapper>
</div>
);
}
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
const Icon = CATEGORY_ICON[cs.category.id];
return (
<motion.a
href="#breakdown"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + index * 0.06 }}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
</div>
<span className="text-sm text-slate-400">{cs.weight}점</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: scoreColor(cs.pct) }}
initial={{ width: 0 }}
animate={{ width: `${cs.pct}%` }}
transition={{ duration: 0.9, ease: 'easeOut', delay: 0.3 + index * 0.06 }}
/>
</div>
<div className="flex justify-between mt-2 text-sm">
<span className="font-semibold">{cs.pct}%</span>
<span className="text-slate-400">
{cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'}
</span>
</div>
</motion.a>
);
}
function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) {
if (severity === 'good') return <span className="text-[#9B8AD4] shrink-0"><CheckFilled size={22} /></span>;
if (severity === 'warning') return <span className="text-[#D4A872] shrink-0"><WarningFilled size={22} /></span>;
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
}
function Tag({ children }: { key?: string; children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
{children}
</span>
);
}
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
return (
<>
<tr className="bg-[#F4F6FB]">
<td colSpan={7} className="px-4 py-2 text-sm font-bold text-[#4A3A7C] tracking-wide">
{categoryName}
</td>
</tr>
{criteria.map((c) => (
<tr key={c.id} className="border-t border-slate-100 align-top">
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
<td className="px-4 py-3">
<p className="font-semibold text-[#0A1128]">{c.name}</p>
<p className="text-sm text-slate-500 mt-1 leading-relaxed">{c.rationale}</p>
<p className="text-sm text-slate-400 mt-1 font-mono">{c.howToCheck}</p>
</td>
<td className="px-4 py-3 font-semibold">{c.weight}</td>
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>
<td className="px-4 py-3 text-slate-600">{CONTROL_LABEL[c.control]}</td>
<td className="px-4 py-3 text-slate-600">{METHOD_LABEL[c.method]}</td>
<td className="px-4 py-3 text-slate-600">{c.levels[3].label}</td>
</tr>
))}
</>
);
}

125
src/types/discovery.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* INFINITH AI Discovery AEO/GEO 채점 타입
*
* 설계 원칙
* - 기준표(rubric)와 결과(result)를 분리한다. 결과는 criterionId → level 매핑만 가진다.
* - 미검증(unverified) 항목은 0점이 아니라 분모에서 제외한다.
* - 모든 항목에 근거 등급(evidenceGrade)과 통제 가능성(control)을 붙인다.
*/
/** 0 = 없음/치명, 1 = 미흡, 2 = 양호, 3 = 우수 */
export type CriterionLevel = 0 | 1 | 2 | 3;
/** 근거 등급 (ado2-aeo-geo 정직성 규약) */
export type EvidenceGrade = 'measured' | 'academic' | 'vendor' | 'estimate';
/** 업체가 통제할 수 있는 정도 */
export type ControlLevel = 'direct' | 'influence' | 'none';
/** 자동 채점 가능 여부 */
export type EvidenceMethod = 'auto' | 'semi' | 'manual';
export type CategoryId =
| 'access'
| 'entity'
| 'content'
| 'surface'
| 'measure'
| 'hygiene';
export interface RubricLevelSpec {
level: CriterionLevel;
label: string;
}
export interface RubricCriterion {
id: string;
category: CategoryId;
name: string;
/** 왜 AI 답변 노출에 영향을 주는가 */
rationale: string;
/** 배점 (카테고리 합 = 카테고리 weight) */
weight: number;
evidenceGrade: EvidenceGrade;
control: ControlLevel;
method: EvidenceMethod;
/** 어떻게 확인하는가 (자동화 시 구현 스펙) */
howToCheck: string;
levels: RubricLevelSpec[];
}
export interface RubricCategory {
id: CategoryId;
code: string;
name: string;
nameEn: string;
description: string;
/** 100점 만점 중 배점 */
weight: number;
}
export interface Rubric {
version: string;
updatedAt: string;
categories: RubricCategory[];
criteria: RubricCriterion[];
}
/** 개별 항목 채점 결과 */
export interface CriterionResult {
criterionId: string;
/** 'unverified' → 분모 제외 */
level: CriterionLevel | 'unverified';
/** 실측 근거 (한 줄) */
evidence: string;
/** 실측 원문·URL 등 */
source?: string;
}
export type ActionPriority = 'P0' | 'P1' | 'P2';
export interface ActionItem {
priority: ActionPriority;
title: string;
detail: string;
/** 연결된 기준 항목 */
criterionIds: string[];
/** 예상 소요 */
effort: '1일' | '1주' | '2~4주' | '지속';
}
export interface DiscoveryResult {
id: string;
clinicName: string;
url: string;
industry: string;
auditedAt: string;
rubricVersion: string;
/** 측정 조건 명시 (정직성 규약) */
conditions: string[];
results: CriterionResult[];
/** AI가 지금 이 업체를 어떻게 읽는지. 핵심 발견 */
keyFindings: { title: string; detail: string; severity: 'critical' | 'warning' | 'good' }[];
actions: ActionItem[];
/** 하지 않을 것 + 이유 */
notDoing: { item: string; reason: string }[];
}
/** 집계 결과 */
export interface CategoryScore {
category: RubricCategory;
earned: number;
possible: number; // 검증된 항목의 배점 합
weight: number; // 원래 배점
pct: number; // 0~100
verifiedCount: number;
totalCount: number;
}
export interface OverallScore {
score: number; // 0~100 (검증 항목 기준 정규화)
earned: number;
possible: number;
categories: CategoryScore[];
grade: 'A' | 'B' | 'C' | 'D';
}