| 1 | <template> |
| 2 | <n-spin :show="loading" class="min-h-40"> |
| 3 | <div class="flex flex-col gap-6"> |
| 4 | <AlertReportReviewPanelReplay :report :existing-review /> |
| 5 | |
| 6 | <AlertReportReviewPanelForm v-model:form="form" :report /> |
| 7 | |
| 8 | <AlertReportReviewPanelIocs v-model:state="iocState" v-model:iocs="iocs" :report /> |
| 9 | |
| 10 | <!-- Submit review --> |
| 11 | <div class="flex flex-col gap-0"> |
| 12 | <div class="flex items-center justify-between gap-3"> |
| 13 | <n-button text size="large" @click="showTeachPalace = !showTeachPalace"> |
| 14 | <template #icon> |
| 15 | <Icon |
| 16 | name="carbon:chevron-right" |
| 17 | class="transition-transform duration-300" |
| 18 | :class="{ 'rotate-90': showTeachPalace }" |
| 19 | /> |
| 20 | </template> |
| 21 | Teach the palace |
| 22 | </n-button> |
| 23 | <n-button :disabled="!canSubmit" :loading="submitting" type="primary" @click="handleSubmit"> |
| 24 | {{ submitting ? "Saving..." : existingReview ? "Update review" : "Submit review" }} |
| 25 | </n-button> |
| 26 | </div> |
| 27 | |
| 28 | <!-- Inline teach-the-palace --> |
| 29 | <n-collapse-transition :show="showTeachPalace"> |
| 30 | <div class="pt-6"> |
| 31 | <AlertReportReviewPanelTeachThePalace :report :existing-review /> |
| 32 | </div> |
| 33 | </n-collapse-transition> |
| 34 | </div> |
| 35 | </div> |
| 36 | </n-spin> |
| 37 | </template> |
| 38 | |
| 39 | <script setup lang="ts"> |
| 40 | import type { FormState } from "./AlertReportReviewPanelForm.vue" |
| 41 | import type { IocState } from "./AlertReportReviewPanelIocs.vue" |
| 42 | import type { |
| 43 | AiAnalystIoc, |
| 44 | AiAnalystReport, |
| 45 | AiAnalystReview, |
| 46 | IocVerdictCorrection, |
| 47 | SubmitReviewPayload |
| 48 | } from "@/types/aiAnalyst.d" |
| 49 | import type { ApiError } from "@/types/common" |
| 50 | import { NButton, NCollapseTransition, NSpin, useMessage } from "naive-ui" |
| 51 | import { computed, onBeforeMount, ref, toRefs } from "vue" |
| 52 | import Api from "@/api" |
| 53 | import Icon from "@/components/common/Icon.vue" |
| 54 | import { getApiErrorMessage } from "@/utils" |
| 55 | import AlertReportReviewPanelForm from "./AlertReportReviewPanelForm.vue" |
| 56 | import AlertReportReviewPanelIocs from "./AlertReportReviewPanelIocs.vue" |
| 57 | import AlertReportReviewPanelReplay from "./AlertReportReviewPanelReplay.vue" |
| 58 | import AlertReportReviewPanelTeachThePalace from "./AlertReportReviewPanelTeachThePalace.vue" |
| 59 | |
| 60 | const props = defineProps<{ |
| 61 | report: AiAnalystReport |
| 62 | }>() |
| 63 | |
| 64 | const { report } = toRefs(props) |
| 65 | |
| 66 | const message = useMessage() |
| 67 | const loading = ref(false) |
| 68 | const submitting = ref(false) |
| 69 | |
| 70 | const iocState = ref<Map<number, IocState>>(new Map()) |
| 71 | const iocs = ref<AiAnalystIoc[]>([]) |
| 72 | |
| 73 | const existingReview = ref<AiAnalystReview | null>(null) |
| 74 | |
| 75 | const form = ref<FormState>({ |
| 76 | overall_verdict: null, |
| 77 | template_choice: null, |
| 78 | rating_instructions: 3, |
| 79 | rating_artifacts: 3, |
| 80 | rating_severity: 3, |
| 81 | missing_steps: "", |
| 82 | suggested_edits: "" |
| 83 | }) |
| 84 | |
| 85 | // --- Teach the palace --- |
| 86 | const showTeachPalace = ref(false) |
| 87 | |
| 88 | // At least overall_verdict must be set |
| 89 | const canSubmit = computed(() => form.value.overall_verdict !== null) |
| 90 | |
| 91 | function hydrateFromReview(r: AiAnalystReview) { |
| 92 | form.value.overall_verdict = (r.overall_verdict as "up" | "down" | null) ?? null |
| 93 | form.value.template_choice = (r.template_choice as "correct" | "wrong" | "partial" | null) ?? null |
| 94 | form.value.rating_instructions = r.rating_instructions ?? 3 |
| 95 | form.value.rating_artifacts = r.rating_artifacts ?? 3 |
| 96 | form.value.rating_severity = r.rating_severity ?? 3 |
| 97 | form.value.missing_steps = r.missing_steps ?? "" |
| 98 | form.value.suggested_edits = r.suggested_edits ?? "" |
| 99 | |
| 100 | const next = new Map<number, IocState>() |
| 101 | |
| 102 | for (const ir of r.ioc_reviews || []) { |
| 103 | next.set(ir.ioc_id, { verdict_correct: ir.verdict_correct, note: ir.note ?? "" }) |
| 104 | } |
| 105 | |
| 106 | iocState.value = next |
| 107 | } |
| 108 | |
| 109 | async function loadReview() { |
| 110 | loading.value = true |
| 111 | |
| 112 | try { |
| 113 | const res = await Api.aiAnalyst.getMyReview(report.value.id) |
| 114 | |
| 115 | if (res.data.success) { |
| 116 | existingReview.value = res.data.review ?? null |
| 117 | if (existingReview.value) hydrateFromReview(existingReview.value) |
| 118 | } |
| 119 | } catch (err) { |
| 120 | message.error(getApiErrorMessage(err as ApiError) || "Failed to load review data") |
| 121 | } finally { |
| 122 | loading.value = false |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | function buildReviewPayload(): SubmitReviewPayload { |
| 127 | const ioc_reviews: IocVerdictCorrection[] = [] |
| 128 | |
| 129 | for (const ioc of iocs.value) { |
| 130 | const st = iocState.value.get(ioc.id) |
| 131 | if (!st) continue |
| 132 | // Only include corrections that represent meaningful input: |
| 133 | // verdict marked wrong, OR reviewer left a note. |
| 134 | if (st.verdict_correct === false || st.note.trim().length > 0) { |
| 135 | ioc_reviews.push({ |
| 136 | ioc_id: ioc.id, |
| 137 | verdict_correct: st.verdict_correct, |
| 138 | ...(st.note.trim() ? { note: st.note.trim() } : {}) |
| 139 | }) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | return { |
| 144 | ...(form.value.overall_verdict ? { overall_verdict: form.value.overall_verdict } : {}), |
| 145 | ...(form.value.template_choice ? { template_choice: form.value.template_choice } : {}), |
| 146 | ...(report.value.report_markdown && existingReview.value?.template_used |
| 147 | ? { template_used: existingReview.value.template_used } |
| 148 | : {}), |
| 149 | rating_instructions: form.value.rating_instructions, |
| 150 | rating_artifacts: form.value.rating_artifacts, |
| 151 | rating_severity: form.value.rating_severity, |
| 152 | ...(form.value.missing_steps.trim() ? { missing_steps: form.value.missing_steps.trim() } : {}), |
| 153 | ...(form.value.suggested_edits.trim() ? { suggested_edits: form.value.suggested_edits.trim() } : {}), |
| 154 | ioc_reviews |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | async function handleSubmit() { |
| 159 | if (!canSubmit.value) return |
| 160 | |
| 161 | submitting.value = true |
| 162 | |
| 163 | try { |
| 164 | const res = await Api.aiAnalyst.submitReview(report.value.id, buildReviewPayload()) |
| 165 | if (res.data.success) { |
| 166 | existingReview.value = res.data.review |
| 167 | message.success(res.data.message || "Review saved") |
| 168 | } else { |
| 169 | message.warning(res.data.message || "Failed to save review") |
| 170 | } |
| 171 | } catch (err) { |
| 172 | message.error(getApiErrorMessage(err as ApiError) || "Failed to save review") |
| 173 | } finally { |
| 174 | submitting.value = false |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | onBeforeMount(() => { |
| 179 | loadReview() |
| 180 | }) |
| 181 | </script> |