- 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>
113 lines
5.8 KiB
TypeScript
113 lines
5.8 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|