1
-<template>
2
- <n-spin :show="loading" class="min-h-40">
3
- <div class="flex flex-col gap-6">
4
- <!-- Toolbar: mode banner + replay trigger -->
5
- <div class="flex flex-wrap items-center justify-between gap-3">
6
- <div v-if="existingReview" class="flex items-center gap-2">
7
- <Badge type="splitted" bright color="success">
8
- <template #label>Already reviewed</template>
9
- <template #value>Editing your previous submission</template>
10
- </Badge>
11
- <span v-if="existingReview.updated_at" class="text-secondary text-sm">
12
- Last edited {{ formatTs(existingReview.updated_at) }}
13
- </span>
14
- <span v-else class="text-secondary text-sm">
15
- Submitted {{ formatTs(existingReview.created_at) }}
16
- </span>
17
- </div>
18
- <div v-else />
19
- <n-button size="small" @click="showReplayModal = true">
20
- <template #icon>
21
- <Icon :name="ReplayIcon" :size="14" />
22
- </template>
23
- Replay with different template
24
- </n-button>
25
- </div>
26
-
27
- <ReplayModal v-model:show="showReplayModal" :report="report" @replayed="onReplayed" />
28
-
29
- <!-- Rubric -->
30
- <CardEntity size="small" embedded>
31
- <template #default>
32
- <div class="flex flex-col gap-4">
33
- <!-- Overall verdict -->
34
- <div>
35
- <div class="mb-1 font-medium">Overall verdict</div>
36
- <n-radio-group v-model:value="form.overall_verdict">
37
- <n-radio-button value="up">
38
- <Icon :name="ThumbUpIcon" :size="14" class="mr-1" />
39
- Good
40
- </n-radio-button>
41
- <n-radio-button value="down">
42
- <Icon :name="ThumbDownIcon" :size="14" class="mr-1" />
43
- Bad
44
- </n-radio-button>
45
- </n-radio-group>
46
- </div>
47
-
48
- <!-- Template choice -->
49
- <div>
50
- <div class="mb-1 font-medium">Template used</div>
51
- <div v-if="report.report_markdown === null" class="text-secondary text-sm">
52
- No template recorded on this report.
53
- </div>
54
- <n-radio-group v-model:value="form.template_choice">
55
- <n-radio value="correct">Correct template</n-radio>
56
- <n-radio value="partial">Partially correct</n-radio>
57
- <n-radio value="wrong">Wrong template</n-radio>
58
- </n-radio-group>
59
- </div>
60
-
61
- <!-- Ratings -->
62
- <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
63
- <div>
64
- <div class="mb-1 font-medium">Instructions quality</div>
65
- <n-slider
66
- v-model:value="form.rating_instructions"
67
- :min="1"
68
- :max="5"
69
- :step="1"
70
- :marks="rateMarks"
71
- />
72
- </div>
73
- <div>
74
- <div class="mb-1 font-medium">Artifact collection</div>
75
- <n-slider
76
- v-model:value="form.rating_artifacts"
77
- :min="1"
78
- :max="5"
79
- :step="1"
80
- :marks="rateMarks"
81
- />
82
- </div>
83
- <div>
84
- <div class="mb-1 font-medium">Severity assessment</div>
85
- <n-slider
86
- v-model:value="form.rating_severity"
87
- :min="1"
88
- :max="5"
89
- :step="1"
90
- :marks="rateMarks"
91
- />
92
- </div>
93
- </div>
94
-
95
- <!-- Missing steps -->
96
- <div>
97
- <div class="mb-1 font-medium">Missing steps</div>
98
- <n-input
99
- v-model:value="form.missing_steps"
100
- type="textarea"
101
- placeholder="What investigation steps were missed?"
102
- :autosize="{ minRows: 2, maxRows: 6 }"
103
- />
104
- </div>
105
-
106
- <!-- Suggested edits -->
107
- <div>
108
- <div class="mb-1 font-medium">Suggested prompt / template edits</div>
109
- <n-input
110
- v-model:value="form.suggested_edits"
111
- type="textarea"
112
- placeholder="How should the template or prompt be improved?"
113
- :autosize="{ minRows: 2, maxRows: 6 }"
114
- />
115
- </div>
116
- </div>
117
- </template>
118
- </CardEntity>
119
-
120
- <!-- Per-IOC verdict corrections -->
121
- <div>
122
- <div class="mb-2 font-medium">IOC verdict corrections</div>
123
- <div class="text-secondary mb-3 text-sm">
124
- Toggle off any IOC where the VirusTotal verdict above was wrong. Optionally note why.
125
- </div>
126
- <div v-if="iocs.length" class="flex flex-col gap-2">
127
- <CardEntity v-for="ioc of iocs" :key="ioc.id" size="small" embedded>
128
- <template #default>
129
- <div class="flex flex-col gap-2">
130
- <div class="flex flex-wrap items-center justify-between gap-3">
131
- <CodeSource :code="ioc.ioc_value" />
132
- <div class="flex items-center gap-3">
133
- <Badge type="splitted" bright>
134
- <template #label>Type</template>
135
- <template #value>{{ ioc.ioc_type }}</template>
136
- </Badge>
137
- <Badge type="splitted" bright :color="verdictColor(ioc.vt_verdict)">
138
- <template #label>VT</template>
139
- <template #value>{{ ioc.vt_verdict }}</template>
140
- </Badge>
141
- <n-tooltip placement="top">
142
- <template #trigger>
143
- <n-switch
144
- :value="iocCorrect(ioc.id)"
145
- @update:value="setIocCorrect(ioc.id, $event)"
146
- />
147
- </template>
148
- {{ iocCorrect(ioc.id) ? "Verdict correct" : "Verdict wrong" }}
149
- </n-tooltip>
150
- </div>
151
- </div>
152
- <n-input
153
- :value="iocNote(ioc.id)"
154
- type="textarea"
155
- placeholder="Optional reviewer note"
156
- :autosize="{ minRows: 1, maxRows: 4 }"
157
- @update:value="setIocNote(ioc.id, $event)"
158
- />
159
- </div>
160
- </template>
161
- </CardEntity>
162
- </div>
163
- <n-empty v-else description="No IOCs recorded for this report" class="min-h-24 justify-center" />
164
- </div>
165
-
166
- <!-- Submit review -->
167
- <div class="flex items-center justify-end gap-3">
168
- <span v-if="submitting" class="text-secondary text-sm">Saving…</span>
169
- <n-button :disabled="submitting || !canSubmit" type="primary" @click="handleSubmit">
170
- {{ existingReview ? "Update review" : "Submit review" }}
171
- </n-button>
172
- </div>
173
-
174
- <!-- Inline teach-the-palace -->
175
- <n-collapse>
176
- <n-collapse-item name="teach-palace">
177
- <template #header>
178
- <div class="flex items-center gap-2 font-medium">
179
- <Icon :name="BrainIcon" :size="16" />
180
- Teach the palace
181
- </div>
182
- </template>
183
- <div class="flex flex-col gap-4 p-2">
184
- <div class="text-secondary text-sm">
185
- Queue a lesson for the MemPalace. The NanoClaw drainer ingests these asynchronously.
186
- </div>
187
- <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
188
- <div>
189
- <div class="mb-1 font-medium">Room</div>
190
- <n-select
191
- v-model:value="lesson.lesson_type"
192
- :options="lessonTypeOptions"
193
- placeholder="Select room"
194
- />
195
- </div>
196
- <div>
197
- <div class="mb-1 font-medium">Durability</div>
198
- <div class="flex items-center gap-3">
199
- <n-switch v-model:value="lessonDurable" />
200
- <span class="text-secondary text-sm">
201
- {{ lessonDurable ? "Durable (persistent)" : "One-off (single session)" }}
202
- </span>
203
- </div>
204
- </div>
205
- </div>
206
- <div>
207
- <div class="mb-1 font-medium">Lesson text</div>
208
- <n-input
209
- v-model:value="lesson.lesson_text"
210
- type="textarea"
211
- placeholder="What should the palace remember?"
212
- :autosize="{ minRows: 3, maxRows: 10 }"
213
- />
214
- </div>
215
-
216
- <!-- Similar-lesson preview -->
217
- <div v-if="similarLoading || similarLessons.length" class="flex flex-col gap-2">
218
- <div class="text-secondary text-sm">
219
- <span v-if="similarLoading">Searching similar lessons…</span>
220
- <span v-else>Similar lessons already in the palace:</span>
221
- </div>
222
- <div v-if="!similarLoading" class="flex flex-col gap-1">
223
- <div
224
- v-for="(hit, idx) of similarLessons"
225
- :key="hit.id ?? idx"
226
- class="border-color bg-secondary rounded border p-2 text-sm"
227
- >
228
- <div class="flex items-center justify-between gap-2">
229
- <span class="text-secondary">
230
- {{ hit.room || "—" }}
231
- <template v-if="hit.score !== null && hit.score !== undefined">
232
- · score {{ hit.score.toFixed(2) }}
233
- </template>
234
- </span>
235
- </div>
236
- <div>{{ hit.text || "(no text)" }}</div>
237
- </div>
238
- </div>
239
- </div>
240
-
241
- <div class="flex items-center justify-end gap-3">
242
- <span v-if="queuing" class="text-secondary text-sm">Queuing…</span>
243
- <n-button :disabled="queuing || !canQueueLesson" @click="handleQueueLesson">
244
- Queue lesson
245
- </n-button>
246
- </div>
247
- </div>
248
- </n-collapse-item>
249
- </n-collapse>
250
- </div>
251
- </n-spin>
252
-</template>
253
-
254
-<script setup lang="ts">
255
-import type {
256
- AiAnalystIoc,
257
- AiAnalystReport,
258
- AiAnalystReview,
259
- Durability,
260
- IocVerdictCorrection,
261
- LessonType,
262
- PalaceSearchHit,
263
- SubmitReviewPayload
264
-} from "@/types/aiAnalyst.d"
265
-import {
266
- NButton,
267
- NCollapse,
268
- NCollapseItem,
269
- NEmpty,
270
- NInput,
271
- NRadio,
272
- NRadioButton,
273
- NRadioGroup,
274
- NSelect,
275
- NSlider,
276
- NSpin,
277
- NSwitch,
278
- NTooltip,
279
- useMessage
280
-} from "naive-ui"
281
-import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
282
-import Api from "@/api"
283
-import Badge from "@/components/common/Badge.vue"
284
-import CardEntity from "@/components/common/cards/CardEntity.vue"
285
-import CodeSource from "@/components/common/CodeSource.vue"
286
-import Icon from "@/components/common/Icon.vue"
287
-import { formatDate } from "@/utils/format"
288
-import ReplayModal from "./ReplayModal.vue"
289
-
290
-const props = defineProps<{
291
- report: AiAnalystReport
292
-}>()
293
-
294
-const { report } = toRefs(props)
295
-
296
-const ThumbUpIcon = "mdi:thumb-up-outline"
297
-const ThumbDownIcon = "mdi:thumb-down-outline"
298
-const BrainIcon = "mdi:brain"
299
-const ReplayIcon = "carbon:restart"
300
-
301
-const showReplayModal = ref(false)
302
-
303
-function onReplayed(_data: Record<string, unknown> | undefined) {
304
- // The new report is created asynchronously by Talon's callbacks. Surface a
305
- // pointer so the reviewer knows where to watch — they can switch to the
306
- // Jobs tab or come back after the run completes.
307
- message.success("Replay queued — check the Jobs tab for the new run", { duration: 6000 })
308
-}
309
-
310
-const message = useMessage()
311
-const loading = ref(false)
312
-const submitting = ref(false)
313
-const queuing = ref(false)
314
-
315
-const existingReview = ref<AiAnalystReview | null>(null)
316
-const iocs = ref<AiAnalystIoc[]>([])
317
-
318
-type FormState = {
319
- overall_verdict: "up" | "down" | null
320
- template_choice: "correct" | "wrong" | "partial" | null
321
- rating_instructions: number
322
- rating_artifacts: number
323
- rating_severity: number
324
- missing_steps: string
325
- suggested_edits: string
326
-}
327
-
328
-const form = ref<FormState>({
329
- overall_verdict: null,
330
- template_choice: null,
331
- rating_instructions: 3,
332
- rating_artifacts: 3,
333
- rating_severity: 3,
334
- missing_steps: "",
335
- suggested_edits: ""
336
-})
337
-
338
-// Per-IOC review state — keyed by ioc.id so order stays stable with the list
339
-type IocState = { verdict_correct: boolean; note: string }
340
-const iocState = ref<Map<number, IocState>>(new Map())
341
-
342
-function iocCorrect(iocId: number): boolean {
343
- return iocState.value.get(iocId)?.verdict_correct ?? true
344
-}
345
-function iocNote(iocId: number): string {
346
- return iocState.value.get(iocId)?.note ?? ""
347
-}
348
-function setIocCorrect(iocId: number, val: boolean) {
349
- const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
350
- iocState.value.set(iocId, { ...cur, verdict_correct: val })
351
-}
352
-function setIocNote(iocId: number, val: string) {
353
- const cur = iocState.value.get(iocId) ?? { verdict_correct: true, note: "" }
354
- iocState.value.set(iocId, { ...cur, note: val })
355
-}
356
-
357
-function verdictColor(verdict: string) {
358
- if (verdict === "malicious") return "danger"
359
- if (verdict === "suspicious") return "warning"
360
- if (verdict === "clean") return "success"
361
- return undefined
362
-}
363
-
364
-const rateMarks = { 1: "1", 2: "2", 3: "3", 4: "4", 5: "5" }
365
-
366
-// At least overall_verdict must be set
367
-const canSubmit = computed(() => form.value.overall_verdict !== null)
368
-
369
-function formatTs(iso: string): string {
370
- return String(formatDate(iso, "MMM D, YYYY HH:mm"))
371
-}
372
-
373
-function hydrateFromReview(r: AiAnalystReview) {
374
- form.value.overall_verdict = (r.overall_verdict as "up" | "down" | null) ?? null
375
- form.value.template_choice = (r.template_choice as "correct" | "wrong" | "partial" | null) ?? null
376
- form.value.rating_instructions = r.rating_instructions ?? 3
377
- form.value.rating_artifacts = r.rating_artifacts ?? 3
378
- form.value.rating_severity = r.rating_severity ?? 3
379
- form.value.missing_steps = r.missing_steps ?? ""
380
- form.value.suggested_edits = r.suggested_edits ?? ""
381
-
382
- const next = new Map<number, IocState>()
383
- for (const ir of r.ioc_reviews || []) {
384
- next.set(ir.ioc_id, { verdict_correct: ir.verdict_correct, note: ir.note ?? "" })
385
- }
386
- iocState.value = next
387
-}
388
-
389
-function seedIocDefaults() {
390
- // Any IOC not yet in state defaults to "verdict correct". Preserves
391
- // per-IOC state hydrated from an existing review.
392
- for (const ioc of iocs.value) {
393
- if (!iocState.value.has(ioc.id)) {
394
- iocState.value.set(ioc.id, { verdict_correct: true, note: "" })
395
- }
396
- }
397
-}
398
-
399
-async function loadAll() {
400
- loading.value = true
401
- try {
402
- const [mineRes, iocsRes] = await Promise.all([
403
- Api.aiAnalyst.getMyReview(report.value.id),
404
- Api.aiAnalyst.getIocsByReport(report.value.id)
405
- ])
406
- if (iocsRes.data.success) iocs.value = iocsRes.data.iocs || []
407
- if (mineRes.data.success) {
408
- existingReview.value = mineRes.data.review ?? null
409
- if (existingReview.value) hydrateFromReview(existingReview.value)
410
- }
411
- seedIocDefaults()
412
- } catch (err: unknown) {
413
- const e = err as { response?: { data?: { message?: string } }; message?: string }
414
- message.error(e.response?.data?.message || e.message || "Failed to load review data")
415
- } finally {
416
- loading.value = false
417
- }
418
-}
419
-
420
-function buildReviewPayload(): SubmitReviewPayload {
421
- const ioc_reviews: IocVerdictCorrection[] = []
422
- for (const ioc of iocs.value) {
423
- const st = iocState.value.get(ioc.id)
424
- if (!st) continue
425
- // Only include corrections that represent meaningful input:
426
- // verdict marked wrong, OR reviewer left a note.
427
- if (st.verdict_correct === false || st.note.trim().length > 0) {
428
- ioc_reviews.push({
429
- ioc_id: ioc.id,
430
- verdict_correct: st.verdict_correct,
431
- ...(st.note.trim() ? { note: st.note.trim() } : {})
432
- })
433
- }
434
- }
435
- return {
436
- ...(form.value.overall_verdict ? { overall_verdict: form.value.overall_verdict } : {}),
437
- ...(form.value.template_choice ? { template_choice: form.value.template_choice } : {}),
438
- ...(report.value.report_markdown && existingReview.value?.template_used
439
- ? { template_used: existingReview.value.template_used }
440
- : {}),
441
- rating_instructions: form.value.rating_instructions,
442
- rating_artifacts: form.value.rating_artifacts,
443
- rating_severity: form.value.rating_severity,
444
- ...(form.value.missing_steps.trim() ? { missing_steps: form.value.missing_steps.trim() } : {}),
445
- ...(form.value.suggested_edits.trim() ? { suggested_edits: form.value.suggested_edits.trim() } : {}),
446
- ioc_reviews
447
- }
448
-}
449
-
450
-async function handleSubmit() {
451
- if (!canSubmit.value) return
452
- submitting.value = true
453
- try {
454
- const res = await Api.aiAnalyst.submitReview(report.value.id, buildReviewPayload())
455
- if (res.data.success) {
456
- existingReview.value = res.data.review
457
- message.success(res.data.message || "Review saved")
458
- } else {
459
- message.warning(res.data.message || "Failed to save review")
460
- }
461
- } catch (err: unknown) {
462
- const e = err as { response?: { data?: { message?: string } }; message?: string }
463
- message.error(e.response?.data?.message || e.message || "Failed to save review")
464
- } finally {
465
- submitting.value = false
466
- }
467
-}
468
-
469
-// --- Teach the palace ---
470
-
471
-const lessonTypeOptions: { label: string; value: LessonType }[] = [
472
- { label: "Environment", value: "environment" },
473
- { label: "False positives", value: "false_positives" },
474
- { label: "Assets", value: "assets" },
475
- { label: "Threat intel", value: "threat_intel" },
476
- { label: "Alerts", value: "alerts" }
477
-]
478
-
479
-const lesson = ref<{ lesson_type: LessonType | null; lesson_text: string }>({
480
- lesson_type: null,
481
- lesson_text: ""
482
-})
483
-const lessonDurable = ref(true)
484
-const lessonDurability = computed<Durability>(() => (lessonDurable.value ? "durable" : "one_off"))
485
-
486
-const canQueueLesson = computed(
487
- () => !!lesson.value.lesson_type && lesson.value.lesson_text.trim().length > 0
488
-)
489
-
490
-async function handleQueueLesson() {
491
- if (!canQueueLesson.value || !lesson.value.lesson_type) return
492
- queuing.value = true
493
- try {
494
- const res = await Api.aiAnalyst.queuePalaceLesson({
495
- customer_code: report.value.customer_code,
496
- lesson_type: lesson.value.lesson_type,
497
- lesson_text: lesson.value.lesson_text.trim(),
498
- durability: lessonDurability.value,
499
- ...(existingReview.value ? { review_id: existingReview.value.id } : {})
500
- })
501
- if (res.data.success) {
502
- message.success(res.data.message || "Lesson queued")
503
- lesson.value.lesson_text = ""
504
- similarLessons.value = []
505
- } else {
506
- message.warning(res.data.message || "Failed to queue lesson")
507
- }
508
- } catch (err: unknown) {
509
- const e = err as { response?: { data?: { message?: string } }; message?: string }
510
- message.error(e.response?.data?.message || e.message || "Failed to queue lesson")
511
- } finally {
512
- queuing.value = false
513
- }
514
-}
515
-
516
-// Debounced similar-lesson preview — re-run when the user pauses typing OR
517
-// changes the room. Keeps the lesson draft honest against what's already stored.
518
-const similarLessons = ref<PalaceSearchHit[]>([])
519
-const similarLoading = ref(false)
520
-let similarTimer: ReturnType<typeof setTimeout> | null = null
521
-
522
-function scheduleSimilarSearch() {
523
- if (similarTimer) clearTimeout(similarTimer)
524
- const text = lesson.value.lesson_text.trim()
525
- if (text.length < 8 || !lesson.value.lesson_type) {
526
- similarLessons.value = []
527
- similarLoading.value = false
528
- return
529
- }
530
- similarLoading.value = true
531
- similarTimer = setTimeout(async () => {
532
- try {
533
- const res = await Api.aiAnalyst.searchPalaceLessons(
534
- report.value.customer_code,
535
- text,
536
- lesson.value.lesson_type ?? undefined,
537
- 5
538
- )
539
- if (res.data.success) similarLessons.value = res.data.lessons || []
540
- else similarLessons.value = []
541
- } catch {
542
- // Non-fatal — preview is best-effort
543
- similarLessons.value = []
544
- } finally {
545
- similarLoading.value = false
546
- }
547
- }, 500)
548
-}
549
-
550
-watch(() => [lesson.value.lesson_text, lesson.value.lesson_type], scheduleSimilarSearch)
551
-
552
-onBeforeMount(() => {
553
- loadAll()
554
-})
555
-</script>