spine-panel.tsx
3,979 bytes
| 1 | 'use client'; |
|---|---|
| 2 | |
| 3 | import { useMemo } from 'react'; |
| 4 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; |
| 5 | import { buildSpineProfiles, type SpineEntry, type SpineVerdict } from '@/core/spine'; |
| 6 | import type { Participant, RoundRecord } from '@/core/types'; |
| 7 | import { participantColor, participantTag } from '@/lib/model-visuals'; |
| 8 | |
| 9 | /** |
| 10 | * Per-model pressure response: for each round, did the model revise, defend, |
| 11 | * cave (change a well-scored answer) or stonewall (keep a poorly-scored one)? |
| 12 | * Computed client-side from records the debate already produced. |
| 13 | */ |
| 14 | export function SpinePanel({ participants, rounds }: { participants: Participant[]; rounds: RoundRecord[] }) { |
| 15 | const profiles = useMemo(() => buildSpineProfiles(participants, rounds), [participants, rounds]); |
| 16 | const withEntries = profiles.filter((p) => p.entries.length > 0); |
| 17 | if (withEntries.length === 0) return null; |
| 18 | |
| 19 | return ( |
| 20 | <div className="space-y-3"> |
| 21 | <p className="text-xs text-muted-foreground"> |
| 22 | How each model handled peer pressure. Caved: changed an answer the council scored highly. |
| 23 | Stonewalled: kept an answer the council scored poorly. |
| 24 | </p> |
| 25 | <div className="space-y-2"> |
| 26 | {withEntries.map((profile) => { |
| 27 | const idx = participants.findIndex((p) => p.id === profile.participantId); |
| 28 | const participant = participants[idx]; |
| 29 | return ( |
| 30 | <div key={profile.participantId} className="flex flex-wrap items-center gap-x-3 gap-y-1.5"> |
| 31 | <div className="flex w-44 min-w-0 items-center gap-1.5"> |
| 32 | <span |
| 33 | className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-bold text-white" |
| 34 | style={{ backgroundColor: participantColor(idx < 0 ? 0 : idx) }} |
| 35 | > |
| 36 | {participantTag(idx < 0 ? 0 : idx)} |
| 37 | </span> |
| 38 | <span className="truncate text-xs font-medium">{participant?.displayName ?? profile.model}</span> |
| 39 | </div> |
| 40 | <div className="flex flex-wrap items-center gap-1.5"> |
| 41 | {profile.entries.map((entry) => ( |
| 42 | <VerdictChip key={entry.round} entry={entry} /> |
| 43 | ))} |
| 44 | </div> |
| 45 | <span className="text-[11px] text-muted-foreground">{summarize(profile.counts, profile.changeRate)}</span> |
| 46 | </div> |
| 47 | ); |
| 48 | })} |
| 49 | </div> |
| 50 | </div> |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | const VERDICT_STYLE: Record<SpineVerdict, string> = { |
| 55 | revised: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30', |
| 56 | defended: 'bg-muted/60 text-muted-foreground border-border', |
| 57 | caved: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30', |
| 58 | stonewalled: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30', |
| 59 | }; |
| 60 | |
| 61 | function VerdictChip({ entry }: { entry: SpineEntry }) { |
| 62 | return ( |
| 63 | <Tooltip> |
| 64 | <TooltipTrigger asChild> |
| 65 | <span |
| 66 | className={`inline-flex cursor-help items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${VERDICT_STYLE[entry.verdict]}`} |
| 67 | > |
| 68 | R{entry.round} {entry.verdict} |
| 69 | </span> |
| 70 | </TooltipTrigger> |
| 71 | <TooltipContent side="top" className="text-xs"> |
| 72 | Round {entry.round}: {entry.changed ? 'changed its answer' : 'kept its answer'} |
| 73 | {entry.meanIncomingScore === null |
| 74 | ? ', no peer scores received' |
| 75 | : `, peers scored it ${entry.meanIncomingScore.toFixed(1)}/10`} |
| 76 | . |
| 77 | </TooltipContent> |
| 78 | </Tooltip> |
| 79 | ); |
| 80 | } |
| 81 | |
| 82 | function summarize(counts: Record<SpineVerdict, number>, changeRate: number | null): string { |
| 83 | const flags: string[] = []; |
| 84 | if (counts.caved > 0) flags.push(`caved ${counts.caved}x`); |
| 85 | if (counts.stonewalled > 0) flags.push(`stonewalled ${counts.stonewalled}x`); |
| 86 | const rate = changeRate === null ? '' : `changed ${Math.round(changeRate * 100)}% of rounds`; |
| 87 | return [rate, ...flags].filter(Boolean).join(' ยท '); |
| 88 | } |
| 89 | |