| 1 | <template> |
| 2 | <div class="@container w-full"> |
| 3 | <div class="grid grid-cols-1 gap-3 @lg:grid-cols-2 @3xl:grid-cols-4"> |
| 4 | <CardLink |
| 5 | v-for="tile of metricTiles" |
| 6 | :key="tile.title" |
| 7 | size="small" |
| 8 | :title="tile.title" |
| 9 | :value="tile.value" |
| 10 | :subtitle="tile.subtitle" |
| 11 | :color="tile.color" |
| 12 | /> |
| 13 | </div> |
| 14 | </div> |
| 15 | </template> |
| 16 | |
| 17 | <script setup lang="ts"> |
| 18 | import type { CardLinkColor } from "@/components/common/cards/CardLink.vue" |
| 19 | import type { AiAnalystReviewStats } from "@/types/aiAnalyst.d" |
| 20 | import { computed, toRefs } from "vue" |
| 21 | import CardLink from "@/components/common/cards/CardLink.vue" |
| 22 | |
| 23 | const props = defineProps<{ |
| 24 | stats: AiAnalystReviewStats |
| 25 | }>() |
| 26 | |
| 27 | const { stats } = toRefs(props) |
| 28 | |
| 29 | // Composite avg across the three rubric axes — only counts axes with data. |
| 30 | const avgOverall = computed(() => { |
| 31 | if (!stats.value) return null |
| 32 | const parts = [ |
| 33 | stats.value.avg_rating_instructions, |
| 34 | stats.value.avg_rating_artifacts, |
| 35 | stats.value.avg_rating_severity |
| 36 | ].filter((v): v is number => v !== null) |
| 37 | if (!parts.length) return null |
| 38 | return parts.reduce((a, b) => a + b, 0) / parts.length |
| 39 | }) |
| 40 | |
| 41 | const ratingSubtitle = computed(() => { |
| 42 | if (!stats.value) return "" |
| 43 | const i = stats.value.avg_rating_instructions |
| 44 | const a = stats.value.avg_rating_artifacts |
| 45 | const s = stats.value.avg_rating_severity |
| 46 | return `instr ${i ?? "—"} · artif ${a ?? "—"} · sev ${s ?? "—"}` |
| 47 | }) |
| 48 | |
| 49 | interface MetricTileConfig { |
| 50 | title: string |
| 51 | value: string | number |
| 52 | subtitle?: string |
| 53 | color?: CardLinkColor |
| 54 | } |
| 55 | |
| 56 | const metricTiles = computed<MetricTileConfig[]>(() => { |
| 57 | const s = stats.value |
| 58 | const avg = avgOverall.value |
| 59 | |
| 60 | return [ |
| 61 | { |
| 62 | title: "Total reviews", |
| 63 | value: s.total_reviews, |
| 64 | subtitle: `correct ${s.template_choice_correct} · partial ${s.template_choice_partial} · wrong ${s.template_choice_wrong}` |
| 65 | }, |
| 66 | { |
| 67 | title: "Thumbs up", |
| 68 | value: pctLabel(s.thumbs_up_pct), |
| 69 | subtitle: `${s.thumbs_up} up / ${s.thumbs_down} down`, |
| 70 | color: pctColor(s.thumbs_up_pct) |
| 71 | }, |
| 72 | { |
| 73 | title: "IOC verdict accuracy", |
| 74 | value: pctLabel(s.ioc_accuracy.accuracy_pct), |
| 75 | subtitle: `${s.ioc_accuracy.correct}/${s.ioc_accuracy.total} IOCs correct`, |
| 76 | color: pctColor(s.ioc_accuracy.accuracy_pct) |
| 77 | }, |
| 78 | { |
| 79 | title: "Avg rating (overall)", |
| 80 | value: avg == null ? "—" : `${avg.toFixed(2)} / 5`, |
| 81 | subtitle: ratingSubtitle.value |
| 82 | } |
| 83 | ] |
| 84 | }) |
| 85 | |
| 86 | function pctLabel(pct: number | null): string { |
| 87 | return pct === null || pct === undefined ? "—" : `${pct.toFixed(1)}%` |
| 88 | } |
| 89 | |
| 90 | function pctColor(pct: number | null): "success" | "warning" | "danger" | undefined { |
| 91 | if (pct === null) return undefined |
| 92 | if (pct >= 75) return "success" |
| 93 | if (pct >= 50) return "warning" |
| 94 | return "danger" |
| 95 | } |
| 96 | </script> |