@cryptotaxi247 / CoPilot / commits / c2e2c6cc

805 ai review (#807)

* feat(ai): add AI review and related tables to the database schema * feat(ai_analyst): add review, palace lesson, and replay endpoints Adds step 16 of the CoPilot ↔ NanoClaw Talon integration: - POST /ai_analyst/reports/{report_id}/review — persist analyst rubric and per-IOC verdict corrections; captures reviewer_user_id for audit - POST /ai_analyst/reports/{report_id}/replay — proxy Talon /investigate with template_override, guarded against cross-tenant replay - POST /ai_analyst/palace_lessons — queue a MemPalace lesson as status=pending for the async drainer to ingest via NanoClaw - GET /ai_analyst/reviews/customer/{customer_code} — dashboard feed with nested IOC reviews - GET /ai_analyst/palace_lessons/customer/{customer_code} — proxy to Talon /palace/search for similar-lessons preview All routes gated with admin|analyst scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add palace lesson drainer scheduled job Adds step 17 of the CoPilot ↔ NanoClaw Talon integration: an APScheduler job that drains queued MemPalace lessons into NanoClaw asynchronously. - New service invoke_palace_lesson_drainer: pulls the 25 oldest pending AiAnalystPalaceLesson rows, POSTs each to Talon /palace/lesson, and marks the row 'ingested' or 'failed'. No auto-retry — failed rows are left for an operator to requeue from the UI. - Registered in scheduler.py with a 2-minute interval; commits per-row so partial progress survives a crash. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): enforce one review per user per report with upsert Add unique constraint on (report_id, reviewer_user_id) plus updated_at column so the UI can show an "already reviewed, edit?" state. submit_review now upserts: validates all IOC references up front, then updates the existing row (replacing ioc_reviews wholesale) or inserts a new one. New GET /reports/{report_id}/review/mine endpoint lets the frontend pre-populate the rubric with the current user's prior submission, or render create-mode when review is null. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): wire frontend types + API clients for review UI Backend: - Add GET /talon/templates proxy (schema + service + route) so the replay picker can list NanoClaw's CoPilot prompt templates without exposing template bodies. Frontend: - Extend types/aiAnalyst.d.ts with AiAnalystReview, AiAnalystIocReview, IocVerdictCorrection, SubmitReviewPayload, AiAnalystPalaceLesson, QueuePalaceLessonPayload, ReplayPayload, PalaceSearchHit, and the related enum literals. - Extend types/talon.d.ts with TalonTemplate. - Add endpoint clients: getMyReview, submitReview, getReviewsByCustomer, replayReport, queuePalaceLesson, searchPalaceLessons, getTemplates. No UI components yet — this is Phase A of Step 18, preparing the plumbing for the AlertReportReviewPanel that lands in Phase B. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add Review tab with rubric + inline teach-palace New AlertReportReviewPanel renders inside a 5th tab on AlertReportDetails. On open it fetches GET /reports/:id/review/mine + GET /iocs/report/:id in parallel — if a prior review exists it hydrates the form and shows an "Already reviewed — editing your previous submission" banner; otherwise it renders in create mode. Submit calls POST /reports/:id/review which upserts on the backend. The rubric covers: overall verdict (thumb up/down), template choice (correct/partial/wrong), three 1–5 sliders (instructions quality, artifact collection, severity assessment accuracy), free-text missing steps + suggested edits, and per-IOC verdict toggles with optional notes. IOC corrections are only submitted when the reviewer toggled the verdict off or left a note, keeping the payload minimal. An inline "Teach the palace" collapse lets the reviewer queue a MemPalace lesson without leaving the modal. Room + lesson text trigger a debounced search against /palace_lessons/customer/:code so the reviewer can see overlap with lessons already stored before queueing. Lessons link back to the review via review_id when one exists. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add replay modal with template picker ReplayModal loads GET /talon/templates and renders the list as clickable cards (filename, first-line preview, size, mtime). Submit kicks off POST /reports/:id/replay with the selected template as template_override and the report's own customer_code, then closes and toasts a pointer to the Jobs tab where the new run shows up. The trigger lives in the Review tab toolbar next to the "already reviewed" banner, so the flow is: read the report → review it → if the template was wrong, pick a better one and re-run without leaving the modal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add side-by-side report comparison tab New "Compare" tab on AlertReportDetails renders two reports for the same alert side-by-side, so an analyst can see how a replay with a different template differs from the original run. AlertReportCompare fetches GET /ai_analyst/reports/alert/:id, defaults A to the currently-opened report (or newest) and B to the next-most-recent run, and exposes two n-select pickers for choosing any pair. Empty state covers the one-report case with a nudge to use Replay. Each column renders in a shared AlertReportCompareColumn: severity badge, report id, created_at, summary, recommended actions, and a collapsible full-markdown section. IOC comparison stays out of scope — the Jobs tab already surfaces per-run artifact deltas. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add feedback dashboard with SQL-side stats rollup Backend: - New GET /ai_analyst/reviews/customer/:code/stats. Rollup is computed on the SQL side via COUNT + AVG + CASE WHEN aggregates so it scales with review count instead of streaming every row into Python. - Three aggregate queries plus a LIMIT 10 recent-reviews select: main rollup (totals, verdict counts, template-choice counts, avg ratings), per-template_used groupby, and IocReview accuracy joined back to the customer's reviews. - Percentage helpers return None when the denominator is 0 so the UI can render a dash instead of a misleading 0%. Frontend: - New AiAnalystReviewStats type + getReviewStats API client. - New Feedback tab on AiAnalyst.vue. FeedbackDashboard shows: - Customer picker (bootstrapped from alerts_with_reports). - Four metric tiles: total reviews, thumbs up %, IOC accuracy %, composite avg rating. - Template choice distribution bars (correct / partial / wrong). - Per-template performance DataTable (sortable columns later). - Recent reviews list with drill-in drawer showing rubric notes and per-IOC corrections. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add palace lesson durability sweeper One-off lessons now carry drawer_id captured from the mempalace add_drawer response. A new hourly scheduler job sweeps ingested one-offs older than ONE_OFF_EXPIRY_DAYS (7d), calls NanoClaw's /palace/forget to remove them from MemPalace, and flips each row to status='expired' with expired_at set. Failures are logged but still flip the row so a stuck drawer never clogs future ticks. - AiAnalystPalaceLesson gets drawer_id + expired_at (+ alembic) - Drainer captures drawer_id from response["data"]["drawer_id"] - invoke_palace_lesson_sweeper service + registered in scheduler - status comment now includes 'expired' as a valid value Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add manual palace lesson consolidation digest New read-only endpoint builds a point-in-time view of a customer's active MemPalace lessons — groups by room, flags near-duplicate pairs via difflib.SequenceMatcher (0.70 threshold), and surfaces one-off lessons within 2 days of the sweeper's expiry window. Pre-renders a markdown digest so the reviewer can copy/export. - GET /ai_analyst/palace_lessons/customer/:code/consolidation - PalaceConsolidationDrawer.vue with summary tiles, dup-pair list, per-room collapse, expiring-soon callout, and copy-markdown button - Wired into FeedbackDashboard as "Consolidate lessons" button - PalaceLessonStatus type now includes "expired" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ai_analyst): stop fetch loop on reports/feedback tabs + fix replay picker Three related issues surfaced during end-to-end testing of the review flow: 1. PalaceConsolidationDrawer's watcher destructured oldValue on `immediate: true`, where Vue passes `undefined`. The TypeError crashed setup → Vue's error recovery remounted the parent tree → the remount refired FeedbackDashboard's bootstrap → /alerts_with_reports spammed at ~7 req/sec. Guard the destructure with a ternary. 2. AlertsReportsList wired `@update:value="getData()"` on the customer filter n-select. customerOptions is a computed that returns a new array every time alertsList updates, and naive-ui can fire update:value when the options prop identity changes — creating a second self-feeding loop. Replaced with watch(customerFilter) so only real value changes refetch. Applied the same defensive pattern to FeedbackDashboard's customer picker (watch(customer, loadStats)) to harden against future computed-options churn. 3. ReplayModal's template picker rendered as visually empty CardEntity cards in the user's theme (the `<code>` filename + `"---"` YAML frontmatter first_line + dim footer all blended into the embedded card background). Swapped to n-radio-group/n-radio with explicit flex layout — native theme support, filename bold, size + updated timestamp clearly readable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ai_analyst): add AI Analyst review workflow documentation * precommit-fixes * precommit-fixes * chore(version): update current version to 0.1.56 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

taylor_socfortress committed Apr 22, 2026 at 15:24 UTC c2e2c6cc2193f7662111de9116837a980c6ba45a
31 files changed +4188 -5
backend/alembic/versions/980bb08dd1cd_add_drawer_id_and_expired_at_to_ai_.py new
+35
@@ -0,0 +1,35 @@
1 +"""Add drawer_id and expired_at to ai_analyst_palace_lesson
2 +
3 +Revision ID: 980bb08dd1cd
4 +Revises: e01d1d2600eb
5 +Create Date: 2026-04-22 14:07:21.240770
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "980bb08dd1cd"
17 +down_revision: Union[str, None] = "e01d1d2600eb"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("ai_analyst_palace_lesson", sa.Column("drawer_id", sa.String(length=64), nullable=True))
25 + op.add_column("ai_analyst_palace_lesson", sa.Column("expired_at", sa.DateTime(), nullable=True))
26 + op.create_index(op.f("ix_ai_analyst_palace_lesson_drawer_id"), "ai_analyst_palace_lesson", ["drawer_id"], unique=False)
27 + # ### end Alembic commands ###
28 +
29 +
30 +def downgrade() -> None:
31 + # ### commands auto generated by Alembic - please adjust! ###
32 + op.drop_index(op.f("ix_ai_analyst_palace_lesson_drawer_id"), table_name="ai_analyst_palace_lesson")
33 + op.drop_column("ai_analyst_palace_lesson", "expired_at")
34 + op.drop_column("ai_analyst_palace_lesson", "drawer_id")
35 + # ### end Alembic commands ###
backend/alembic/versions/e01d1d2600eb_update_ai_review_tables.py new
+33
@@ -0,0 +1,33 @@
1 +"""update ai review tables
2 +
3 +Revision ID: e01d1d2600eb
4 +Revises: f75a3f9bb316
5 +Create Date: 2026-04-22 12:22:29.708523
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "e01d1d2600eb"
17 +down_revision: Union[str, None] = "f75a3f9bb316"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.add_column("ai_analyst_review", sa.Column("updated_at", sa.DateTime(), nullable=True))
25 + op.create_unique_constraint("uq_ai_analyst_review_report_reviewer", "ai_analyst_review", ["report_id", "reviewer_user_id"])
26 + # ### end Alembic commands ###
27 +
28 +
29 +def downgrade() -> None:
30 + # ### commands auto generated by Alembic - please adjust! ###
31 + op.drop_constraint("uq_ai_analyst_review_report_reviewer", "ai_analyst_review", type_="unique")
32 + op.drop_column("ai_analyst_review", "updated_at")
33 + # ### end Alembic commands ###
backend/alembic/versions/f75a3f9bb316_add_ai_review_tables.py new
+121
@@ -0,0 +1,121 @@
1 +"""Add ai review tables
2 +
3 +Revision ID: f75a3f9bb316
4 +Revises: 41a7b41cd83a
5 +Create Date: 2026-04-22 11:19:24.303540
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "f75a3f9bb316"
17 +down_revision: Union[str, None] = "41a7b41cd83a"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "ai_analyst_review",
26 + sa.Column("missing_steps", sa.Text(), nullable=True),
27 + sa.Column("suggested_edits", sa.Text(), nullable=True),
28 + sa.Column("id", sa.Integer(), nullable=False),
29 + sa.Column("report_id", sa.Integer(), nullable=False),
30 + sa.Column("alert_id", sa.Integer(), nullable=False),
31 + sa.Column("customer_code", sa.String(length=64), nullable=False),
32 + sa.Column("reviewer_user_id", sa.Integer(), nullable=False),
33 + sa.Column("overall_verdict", sa.String(length=4), nullable=True),
34 + sa.Column("template_choice", sa.String(length=7), nullable=True),
35 + sa.Column("template_used", sa.String(length=128), nullable=True),
36 + sa.Column("rating_instructions", sa.Integer(), nullable=True),
37 + sa.Column("rating_artifacts", sa.Integer(), nullable=True),
38 + sa.Column("rating_severity", sa.Integer(), nullable=True),
39 + sa.Column("created_at", sa.DateTime(), nullable=False),
40 + sa.ForeignKeyConstraint(
41 + ["customer_code"],
42 + ["customers.customer_code"],
43 + ),
44 + sa.ForeignKeyConstraint(
45 + ["report_id"],
46 + ["ai_analyst_report.id"],
47 + ),
48 + sa.PrimaryKeyConstraint("id"),
49 + )
50 + op.create_index(op.f("ix_ai_analyst_review_alert_id"), "ai_analyst_review", ["alert_id"], unique=False)
51 + op.create_index(op.f("ix_ai_analyst_review_created_at"), "ai_analyst_review", ["created_at"], unique=False)
52 + op.create_index(op.f("ix_ai_analyst_review_customer_code"), "ai_analyst_review", ["customer_code"], unique=False)
53 + op.create_index(op.f("ix_ai_analyst_review_report_id"), "ai_analyst_review", ["report_id"], unique=False)
54 + op.create_index(op.f("ix_ai_analyst_review_reviewer_user_id"), "ai_analyst_review", ["reviewer_user_id"], unique=False)
55 + op.create_table(
56 + "ai_analyst_ioc_review",
57 + sa.Column("note", sa.Text(), nullable=True),
58 + sa.Column("id", sa.Integer(), nullable=False),
59 + sa.Column("review_id", sa.Integer(), nullable=False),
60 + sa.Column("ioc_id", sa.Integer(), nullable=False),
61 + sa.Column("verdict_correct", sa.Boolean(), nullable=False),
62 + sa.Column("created_at", sa.DateTime(), nullable=False),
63 + sa.ForeignKeyConstraint(
64 + ["ioc_id"],
65 + ["ai_analyst_ioc.id"],
66 + ),
67 + sa.ForeignKeyConstraint(
68 + ["review_id"],
69 + ["ai_analyst_review.id"],
70 + ),
71 + sa.PrimaryKeyConstraint("id"),
72 + )
73 + op.create_index(op.f("ix_ai_analyst_ioc_review_created_at"), "ai_analyst_ioc_review", ["created_at"], unique=False)
74 + op.create_index(op.f("ix_ai_analyst_ioc_review_ioc_id"), "ai_analyst_ioc_review", ["ioc_id"], unique=False)
75 + op.create_index(op.f("ix_ai_analyst_ioc_review_review_id"), "ai_analyst_ioc_review", ["review_id"], unique=False)
76 + op.create_table(
77 + "ai_analyst_palace_lesson",
78 + sa.Column("lesson_text", sa.Text(), nullable=True),
79 + sa.Column("id", sa.Integer(), nullable=False),
80 + sa.Column("review_id", sa.Integer(), nullable=True),
81 + sa.Column("customer_code", sa.String(length=64), nullable=False),
82 + sa.Column("lesson_type", sa.String(length=20), nullable=False),
83 + sa.Column("durability", sa.String(length=8), nullable=False),
84 + sa.Column("status", sa.String(length=8), nullable=False),
85 + sa.Column("ingested_at", sa.DateTime(), nullable=True),
86 + sa.Column("created_at", sa.DateTime(), nullable=False),
87 + sa.ForeignKeyConstraint(
88 + ["customer_code"],
89 + ["customers.customer_code"],
90 + ),
91 + sa.ForeignKeyConstraint(
92 + ["review_id"],
93 + ["ai_analyst_review.id"],
94 + ),
95 + sa.PrimaryKeyConstraint("id"),
96 + )
97 + op.create_index(op.f("ix_ai_analyst_palace_lesson_created_at"), "ai_analyst_palace_lesson", ["created_at"], unique=False)
98 + op.create_index(op.f("ix_ai_analyst_palace_lesson_customer_code"), "ai_analyst_palace_lesson", ["customer_code"], unique=False)
99 + op.create_index(op.f("ix_ai_analyst_palace_lesson_review_id"), "ai_analyst_palace_lesson", ["review_id"], unique=False)
100 + op.create_index(op.f("ix_ai_analyst_palace_lesson_status"), "ai_analyst_palace_lesson", ["status"], unique=False)
101 + # ### end Alembic commands ###
102 +
103 +
104 +def downgrade() -> None:
105 + # ### commands auto generated by Alembic - please adjust! ###
106 + op.drop_index(op.f("ix_ai_analyst_palace_lesson_status"), table_name="ai_analyst_palace_lesson")
107 + op.drop_index(op.f("ix_ai_analyst_palace_lesson_review_id"), table_name="ai_analyst_palace_lesson")
108 + op.drop_index(op.f("ix_ai_analyst_palace_lesson_customer_code"), table_name="ai_analyst_palace_lesson")
109 + op.drop_index(op.f("ix_ai_analyst_palace_lesson_created_at"), table_name="ai_analyst_palace_lesson")
110 + op.drop_table("ai_analyst_palace_lesson")
111 + op.drop_index(op.f("ix_ai_analyst_ioc_review_review_id"), table_name="ai_analyst_ioc_review")
112 + op.drop_index(op.f("ix_ai_analyst_ioc_review_ioc_id"), table_name="ai_analyst_ioc_review")
113 + op.drop_index(op.f("ix_ai_analyst_ioc_review_created_at"), table_name="ai_analyst_ioc_review")
114 + op.drop_table("ai_analyst_ioc_review")
115 + op.drop_index(op.f("ix_ai_analyst_review_reviewer_user_id"), table_name="ai_analyst_review")
116 + op.drop_index(op.f("ix_ai_analyst_review_report_id"), table_name="ai_analyst_review")
117 + op.drop_index(op.f("ix_ai_analyst_review_customer_code"), table_name="ai_analyst_review")
118 + op.drop_index(op.f("ix_ai_analyst_review_created_at"), table_name="ai_analyst_review")
119 + op.drop_index(op.f("ix_ai_analyst_review_alert_id"), table_name="ai_analyst_review")
120 + op.drop_table("ai_analyst_review")
121 + # ### end Alembic commands ###
backend/app/ai_analyst/routes/ai_analyst.py
+222
@@ -2,6 +2,7 @@ from typing import Optional
2
3 from fastapi import APIRouter
4 from fastapi import Depends
5 +from fastapi import HTTPException
6 from fastapi import Query
7 from fastapi import Security
8 from loguru import logger
@@ -13,16 +14,30 @@ from app.ai_analyst.schema.ai_analyst import CreateJobRequest
14 from app.ai_analyst.schema.ai_analyst import CreateJobResponse
15 from app.ai_analyst.schema.ai_analyst import IocListResponse
16 from app.ai_analyst.schema.ai_analyst import JobListResponse
17 +from app.ai_analyst.schema.ai_analyst import MyReviewResponse
18 +from app.ai_analyst.schema.ai_analyst import PalaceConsolidationResponse
19 +from app.ai_analyst.schema.ai_analyst import PalaceSearchResponse
20 +from app.ai_analyst.schema.ai_analyst import QueuePalaceLessonRequest
21 +from app.ai_analyst.schema.ai_analyst import QueuePalaceLessonResponse
22 +from app.ai_analyst.schema.ai_analyst import ReplayRequest
23 +from app.ai_analyst.schema.ai_analyst import ReplayResponse
24 from app.ai_analyst.schema.ai_analyst import ReportListResponse
25 +from app.ai_analyst.schema.ai_analyst import ReviewListResponse
26 +from app.ai_analyst.schema.ai_analyst import ReviewStatsResponse
27 from app.ai_analyst.schema.ai_analyst import SubmitIocsRequest
28 from app.ai_analyst.schema.ai_analyst import SubmitIocsResponse
29 from app.ai_analyst.schema.ai_analyst import SubmitReportRequest
30 from app.ai_analyst.schema.ai_analyst import SubmitReportResponse
31 +from app.ai_analyst.schema.ai_analyst import SubmitReviewRequest
32 +from app.ai_analyst.schema.ai_analyst import SubmitReviewResponse
33 from app.ai_analyst.schema.ai_analyst import UpdateJobRequest
34 from app.ai_analyst.schema.ai_analyst import UpdateJobResponse
35 from app.ai_analyst.services.ai_analyst import create_job
36 from app.ai_analyst.services.ai_analyst import get_alert_analysis
37 from app.ai_analyst.services.ai_analyst import get_job
38 +from app.ai_analyst.services.ai_analyst import get_my_review
39 +from app.ai_analyst.services.ai_analyst import get_palace_consolidation
40 +from app.ai_analyst.services.ai_analyst import get_review_stats
41 from app.ai_analyst.services.ai_analyst import list_alerts_with_reports
42 from app.ai_analyst.services.ai_analyst import list_iocs_by_alert
43 from app.ai_analyst.services.ai_analyst import list_iocs_by_customer
@@ -30,11 +45,22 @@ from app.ai_analyst.services.ai_analyst import list_iocs_by_report
45 from app.ai_analyst.services.ai_analyst import list_jobs_by_alert
46 from app.ai_analyst.services.ai_analyst import list_jobs_by_customer
47 from app.ai_analyst.services.ai_analyst import list_reports_by_alert
48 +from app.ai_analyst.services.ai_analyst import list_reviews_by_customer
49 +from app.ai_analyst.services.ai_analyst import queue_palace_lesson
50 from app.ai_analyst.services.ai_analyst import submit_iocs
51 from app.ai_analyst.services.ai_analyst import submit_report
52 +from app.ai_analyst.services.ai_analyst import submit_review
53 from app.ai_analyst.services.ai_analyst import update_job
54 +from app.auth.models.users import User
55 from app.auth.utils import AuthHandler
56 +from app.connectors.talon.services.talon import (
57 + replay_investigation as talon_replay_investigation,
58 +)
59 +from app.connectors.talon.services.talon import (
60 + search_palace_lessons as talon_search_palace_lessons,
61 +)
62 from app.db.db_session import get_db
63 +from app.db.universal_models import AiAnalystReport
64
65 ai_analyst_router = APIRouter()
66
@@ -248,3 +274,199 @@ async def get_alert_analysis_route(
274 report=report,
275 iocs=iocs,
276 )
277 +
278 +
279 +# --- Review / Palace lesson / Replay endpoints ---
280 +
281 +
282 +@ai_analyst_router.post(
283 + "/reports/{report_id}/review",
284 + response_model=SubmitReviewResponse,
285 + description="Submit an analyst review (rubric + IOC corrections) for a report",
286 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
287 +)
288 +async def submit_review_route(
289 + report_id: int,
290 + request: SubmitReviewRequest,
291 + current_user: User = Depends(AuthHandler().get_current_user),
292 + session: AsyncSession = Depends(get_db),
293 +) -> SubmitReviewResponse:
294 + """
295 + Persist an analyst review rubric + per-IOC corrections. Scope gated
296 + (admin OR analyst) via require_any_scope; the authenticated user's id is
297 + captured as reviewer_user_id for audit.
298 + """
299 + logger.info(f"User {current_user.id} submitting review for report {report_id}")
300 + return await submit_review(
301 + report_id=report_id,
302 + request=request,
303 + reviewer_user_id=current_user.id,
304 + session=session,
305 + )
306 +
307 +
308 +@ai_analyst_router.get(
309 + "/reports/{report_id}/review/mine",
310 + response_model=MyReviewResponse,
311 + description="Fetch the current user's existing review for a report (returns review=null if none yet)",
312 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
313 +)
314 +async def get_my_review_route(
315 + report_id: int,
316 + current_user: User = Depends(AuthHandler().get_current_user),
317 + session: AsyncSession = Depends(get_db),
318 +) -> MyReviewResponse:
319 + """UI calls this on open to decide between create-mode and edit-existing-mode."""
320 + return await get_my_review(
321 + report_id=report_id,
322 + reviewer_user_id=current_user.id,
323 + session=session,
324 + )
325 +
326 +
327 +@ai_analyst_router.post(
328 + "/reports/{report_id}/replay",
329 + response_model=ReplayResponse,
330 + description="Replay an investigation for the given report's alert with a forced template override",
331 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
332 +)
333 +async def replay_report_route(
334 + report_id: int,
335 + request: ReplayRequest,
336 + session: AsyncSession = Depends(get_db),
337 +) -> ReplayResponse:
338 + """
339 + Triggers Talon POST /investigate with template_override. The new run will
340 + create its own AiAnalystJob/Report via Talon's existing webhook callbacks —
341 + this endpoint does not mutate local DB itself.
342 + """
343 + report = await session.get(AiAnalystReport, report_id)
344 + if not report:
345 + raise HTTPException(status_code=404, detail=f"Report {report_id} not found")
346 +
347 + # Guard: customer_code from the client must match the report's, preventing
348 + # replay injection across tenants
349 + if request.customer_code != report.customer_code:
350 + raise HTTPException(
351 + status_code=400,
352 + detail=(f"customer_code mismatch: report belongs to {report.customer_code}, " f"got {request.customer_code}"),
353 + )
354 +
355 + logger.info(
356 + f"Replaying investigation for report {report_id} (alert {report.alert_id}) " f"with template_override={request.template_override}",
357 + )
358 + talon_response = await talon_replay_investigation(
359 + alert_id=report.alert_id,
360 + customer_code=request.customer_code,
361 + template_override=request.template_override,
362 + sender=request.sender,
363 + )
364 + return ReplayResponse(
365 + success=True,
366 + message="Replay triggered",
367 + data=talon_response.get("data"),
368 + )
369 +
370 +
371 +@ai_analyst_router.post(
372 + "/palace_lessons",
373 + response_model=QueuePalaceLessonResponse,
374 + description="Queue a MemPalace lesson for async ingestion by the NanoClaw drainer",
375 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
376 +)
377 +async def queue_palace_lesson_route(
378 + request: QueuePalaceLessonRequest,
379 + session: AsyncSession = Depends(get_db),
380 +) -> QueuePalaceLessonResponse:
381 + logger.info(f"Queuing palace lesson for customer {request.customer_code}")
382 + return await queue_palace_lesson(request, session)
383 +
384 +
385 +@ai_analyst_router.get(
386 + "/reviews/customer/{customer_code}",
387 + response_model=ReviewListResponse,
388 + description="Review dashboard feed for a customer (newest first, with per-IOC reviews)",
389 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
390 +)
391 +async def list_reviews_by_customer_route(
392 + customer_code: str,
393 + session: AsyncSession = Depends(get_db),
394 +) -> ReviewListResponse:
395 + reviews = await list_reviews_by_customer(customer_code, session)
396 + return ReviewListResponse(
397 + success=True,
398 + message=f"{len(reviews)} reviews retrieved",
399 + reviews=reviews,
400 + )
401 +
402 +
403 +@ai_analyst_router.get(
404 + "/reviews/customer/{customer_code}/stats",
405 + response_model=ReviewStatsResponse,
406 + description="Aggregate review metrics (feedback dashboard) for a customer — SQL-side rollup",
407 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
408 +)
409 +async def get_review_stats_route(
410 + customer_code: str,
411 + recent_limit: int = Query(10, ge=0, le=50, description="How many recent reviews to embed"),
412 + session: AsyncSession = Depends(get_db),
413 +) -> ReviewStatsResponse:
414 + logger.info(f"Fetching review stats for customer {customer_code}")
415 + return await get_review_stats(
416 + customer_code=customer_code,
417 + session=session,
418 + recent_limit=recent_limit,
419 + )
420 +
421 +
422 +@ai_analyst_router.get(
423 + "/palace_lessons/customer/{customer_code}",
424 + response_model=PalaceSearchResponse,
425 + description="Preview similar MemPalace lessons for a customer (proxies to Talon /palace/search)",
426 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
427 +)
428 +async def search_palace_lessons_route(
429 + customer_code: str,
430 + query: str = Query(..., min_length=1, description="Semantic search query"),
431 + room: Optional[str] = Query(None, description="Optional room filter"),
432 + limit: int = Query(5, ge=1, le=25, description="Max hits"),
433 +) -> PalaceSearchResponse:
434 + logger.info(
435 + f"Searching palace for customer={customer_code} query={query!r} room={room} limit={limit}",
436 + )
437 + talon_response = await talon_search_palace_lessons(
438 + customer_code=customer_code,
439 + query=query,
440 + room=room,
441 + limit=limit,
442 + )
443 + # Talon returns {data: {...}} — try to extract the lessons list regardless of shape
444 + data = talon_response.get("data") or {}
445 + raw_lessons = data.get("lessons") if isinstance(data, dict) else None
446 + if raw_lessons is None and isinstance(data, list):
447 + raw_lessons = data
448 + if raw_lessons is None:
449 + raw_lessons = []
450 + return PalaceSearchResponse(
451 + success=True,
452 + message=f"{len(raw_lessons)} palace lessons matched",
453 + lessons=raw_lessons,
454 + )
455 +
456 +
457 +@ai_analyst_router.get(
458 + "/palace_lessons/customer/{customer_code}/consolidation",
459 + response_model=PalaceConsolidationResponse,
460 + description=(
461 + "Manual consolidation digest for a customer's active MemPalace lessons — "
462 + "groups by room, flags near-duplicate pairs, and surfaces one-offs about "
463 + "to be swept. Pure read-only; no Talon round-trip."
464 + ),
465 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
466 +)
467 +async def get_palace_consolidation_route(
468 + customer_code: str,
469 + session: AsyncSession = Depends(get_db),
470 +) -> PalaceConsolidationResponse:
471 + logger.info(f"Building palace consolidation for customer {customer_code}")
472 + return await get_palace_consolidation(customer_code, session)
backend/app/ai_analyst/schema/ai_analyst.py
+293
@@ -49,6 +49,36 @@ class VtVerdict(str, Enum):
49 UNKNOWN = "unknown"
50
51
52 +class OverallVerdict(str, Enum):
53 + UP = "up"
54 + DOWN = "down"
55 +
56 +
57 +class TemplateChoice(str, Enum):
58 + CORRECT = "correct"
59 + WRONG = "wrong"
60 + PARTIAL = "partial"
61 +
62 +
63 +class LessonType(str, Enum):
64 + ENVIRONMENT = "environment"
65 + FALSE_POSITIVES = "false_positives"
66 + ASSETS = "assets"
67 + THREAT_INTEL = "threat_intel"
68 + ALERTS = "alerts"
69 +
70 +
71 +class Durability(str, Enum):
72 + ONE_OFF = "one_off"
73 + DURABLE = "durable"
74 +
75 +
76 +class PalaceLessonStatus(str, Enum):
77 + PENDING = "pending"
78 + INGESTED = "ingested"
79 + FAILED = "failed"
80 +
81 +
82 # --- Request schemas ---
83
84
@@ -210,3 +240,266 @@ class AlertAnalysisResponse(BaseModel):
240 job: Optional[JobResponse] = None
241 report: Optional[ReportResponse] = None
242 iocs: Optional[List[IocResponse]] = None
243 +
244 +
245 +# --- Review / Palace Lesson / Replay schemas ---
246 +
247 +
248 +class IocVerdictCorrection(BaseModel):
249 + ioc_id: int = Field(..., description="The AiAnalystIoc.id being reviewed")
250 + verdict_correct: bool = Field(..., description="True if the original VT verdict was correct")
251 + note: Optional[str] = Field(None, max_length=2000, description="Optional reviewer note")
252 +
253 + @validator("note", pre=True)
254 + def strip_control_characters_note(cls, v):
255 + if v is None:
256 + return v
257 + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", v)
258 +
259 +
260 +class SubmitReviewRequest(BaseModel):
261 + overall_verdict: Optional[OverallVerdict] = Field(None, description="Overall thumbs up/down")
262 + template_choice: Optional[TemplateChoice] = Field(None, description="Was the selected template correct")
263 + template_used: Optional[str] = Field(None, max_length=128, description="Template filename that ran (mirrored from report)")
264 + rating_instructions: Optional[int] = Field(None, ge=1, le=5, description="Rating 1–5 on instructions quality")
265 + rating_artifacts: Optional[int] = Field(None, ge=1, le=5, description="Rating 1–5 on collected artifacts")
266 + rating_severity: Optional[int] = Field(None, ge=1, le=5, description="Rating 1–5 on severity assessment accuracy")
267 + missing_steps: Optional[str] = Field(None, description="Free-text list of steps the analyst missed")
268 + suggested_edits: Optional[str] = Field(None, description="Free-text suggested prompt / template edits")
269 + ioc_reviews: List[IocVerdictCorrection] = Field(default_factory=list, description="Per-IOC verdict corrections")
270 +
271 + @validator("missing_steps", "suggested_edits", pre=True)
272 + def strip_control_characters(cls, v):
273 + if v is None:
274 + return v
275 + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", v)
276 +
277 +
278 +class IocReviewResponse(BaseModel):
279 + id: int
280 + review_id: int
281 + ioc_id: int
282 + verdict_correct: bool
283 + note: Optional[str]
284 + created_at: datetime
285 +
286 +
287 +class ReviewResponse(BaseModel):
288 + id: int
289 + report_id: int
290 + alert_id: int
291 + customer_code: str
292 + reviewer_user_id: int
293 + overall_verdict: Optional[str]
294 + template_choice: Optional[str]
295 + template_used: Optional[str]
296 + rating_instructions: Optional[int]
297 + rating_artifacts: Optional[int]
298 + rating_severity: Optional[int]
299 + missing_steps: Optional[str]
300 + suggested_edits: Optional[str]
301 + created_at: datetime
302 + updated_at: Optional[datetime] = None
303 + ioc_reviews: List[IocReviewResponse] = Field(default_factory=list)
304 +
305 +
306 +class MyReviewResponse(BaseModel):
307 + """Response for 'fetch my existing review for this report' — used by the UI to
308 + decide whether to show the rubric in create mode or edit-existing mode."""
309 +
310 + success: bool
311 + message: str
312 + review: Optional[ReviewResponse] = None
313 +
314 +
315 +class SubmitReviewResponse(BaseModel):
316 + success: bool
317 + message: str
318 + review: Optional[ReviewResponse] = None
319 +
320 +
321 +class ReviewListResponse(BaseModel):
322 + success: bool
323 + message: str
324 + reviews: List[ReviewResponse]
325 +
326 +
327 +class QueuePalaceLessonRequest(BaseModel):
328 + customer_code: str = Field(..., max_length=64, description="Customer code this lesson applies to")
329 + lesson_type: LessonType = Field(..., description="MemPalace room / category")
330 + lesson_text: str = Field(..., min_length=1, description="The lesson text to store")
331 + durability: Durability = Field(default=Durability.DURABLE, description="one_off = single-session hint, durable = persistent knowledge")
332 + review_id: Optional[int] = Field(None, description="Optional review.id this lesson was born from")
333 +
334 + @validator("lesson_text", pre=True)
335 + def strip_control_characters_lesson(cls, v):
336 + if v is None:
337 + return v
338 + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", v)
339 +
340 +
341 +class PalaceLessonResponse(BaseModel):
342 + id: int
343 + review_id: Optional[int]
344 + customer_code: str
345 + lesson_type: str
346 + lesson_text: str
347 + durability: str
348 + status: str
349 + ingested_at: Optional[datetime]
350 + created_at: datetime
351 +
352 +
353 +class QueuePalaceLessonResponse(BaseModel):
354 + success: bool
355 + message: str
356 + lesson: Optional[PalaceLessonResponse] = None
357 +
358 +
359 +class ReplayRequest(BaseModel):
360 + template_override: str = Field(
361 + ...,
362 + max_length=128,
363 + description="Template filename to force for this replay (e.g. sysmon_event_1.txt)",
364 + )
365 + customer_code: str = Field(..., max_length=64, description="Customer code for the alert")
366 + sender: str = Field(default="copilot-replay", max_length=64, description="Sender identifier for audit")
367 +
368 + @validator("template_override")
369 + def validate_template_filename(cls, v):
370 + if not re.match(r"^[a-zA-Z0-9._-]+\.txt$", v):
371 + raise ValueError("template_override must be a filename matching ^[a-zA-Z0-9._-]+\\.txt$")
372 + return v
373 +
374 +
375 +class ReplayResponse(BaseModel):
376 + success: bool
377 + message: str
378 + data: Optional[dict] = None
379 +
380 +
381 +class PalaceSearchHit(BaseModel):
382 + id: Optional[str] = None
383 + room: Optional[str] = None
384 + wing: Optional[str] = None
385 + text: Optional[str] = None
386 + source_file: Optional[str] = None
387 + score: Optional[float] = None
388 + metadata: Optional[dict] = None
389 +
390 +
391 +class PalaceSearchResponse(BaseModel):
392 + success: bool
393 + message: str
394 + lessons: List[PalaceSearchHit] = Field(default_factory=list)
395 +
396 +
397 +# --- Review stats / feedback dashboard ---
398 +
399 +
400 +class ReviewStatsTemplate(BaseModel):
401 + """Per-template slice of review metrics (grouped by template_used)."""
402 +
403 + template_used: Optional[str] = Field(None, description="Template filename, or None for untemplated runs")
404 + total: int = 0
405 + thumbs_up: int = 0
406 + thumbs_down: int = 0
407 + correct: int = 0
408 + partial: int = 0
409 + wrong: int = 0
410 + avg_rating_instructions: Optional[float] = None
411 + avg_rating_artifacts: Optional[float] = None
412 + avg_rating_severity: Optional[float] = None
413 +
414 +
415 +class ReviewStatsIocAccuracy(BaseModel):
416 + """Aggregate IOC verdict accuracy — derived from analyst per-IOC corrections."""
417 +
418 + total: int = 0
419 + correct: int = 0
420 + incorrect: int = 0
421 + accuracy_pct: Optional[float] = None
422 +
423 +
424 +class ReviewStatsResponse(BaseModel):
425 + success: bool
426 + message: str
427 + customer_code: str
428 + total_reviews: int = 0
429 + thumbs_up: int = 0
430 + thumbs_down: int = 0
431 + thumbs_up_pct: Optional[float] = None
432 + template_choice_correct: int = 0
433 + template_choice_partial: int = 0
434 + template_choice_wrong: int = 0
435 + avg_rating_instructions: Optional[float] = None
436 + avg_rating_artifacts: Optional[float] = None
437 + avg_rating_severity: Optional[float] = None
438 + ioc_accuracy: ReviewStatsIocAccuracy = Field(default_factory=ReviewStatsIocAccuracy)
439 + per_template: List[ReviewStatsTemplate] = Field(default_factory=list)
440 + recent_reviews: List[ReviewResponse] = Field(default_factory=list)
441 +
442 +
443 +# --- Palace consolidation (Step 21.B) ---
444 +
445 +
446 +class PalaceConsolidationLesson(BaseModel):
447 + """A single lesson row, shaped for the consolidation digest UI."""
448 +
449 + id: int
450 + lesson_type: str
451 + lesson_text: str
452 + durability: str
453 + status: str
454 + drawer_id: Optional[str] = None
455 + created_at: datetime
456 + ingested_at: Optional[datetime] = None
457 + # For one_off lessons only — how many days until the sweeper expires
458 + # this row. Negative numbers mean the sweeper is about to take it on
459 + # the next tick. None for durable rows (no expiry).
460 + days_until_expiry: Optional[int] = None
461 +
462 +
463 +class PalaceConsolidationRoomGroup(BaseModel):
464 + """Per-room slice — lessons grouped by lesson_type."""
465 +
466 + room: str
467 + total: int
468 + durable: int
469 + one_off: int
470 + lessons: List[PalaceConsolidationLesson] = Field(default_factory=list)
471 +
472 +
473 +class PalaceConsolidationDuplicatePair(BaseModel):
474 + """Near-duplicate candidate flagged for reviewer attention."""
475 +
476 + room: str
477 + lesson_a_id: int
478 + lesson_b_id: int
479 + lesson_a_text: str
480 + lesson_b_text: str
481 + similarity: float # 0.0 – 1.0, difflib SequenceMatcher ratio
482 +
483 +
484 +class PalaceConsolidationResponse(BaseModel):
485 + """Full digest for a customer — renders inline in a drawer; the
486 + reviewer can also grab the pre-rendered markdown for export."""
487 +
488 + success: bool
489 + message: str
490 + customer_code: str
491 + generated_at: datetime
492 + # Top-level counts across active (non-expired, non-failed) lessons
493 + total_lessons: int = 0
494 + total_durable: int = 0
495 + total_one_off: int = 0
496 + total_pending: int = 0
497 + total_ingested: int = 0
498 + # One-off lessons whose expiry is within SOON_WINDOW_DAYS — reviewer
499 + # may want to promote them to durable before the sweeper deletes them.
500 + upcoming_expirations: List[PalaceConsolidationLesson] = Field(default_factory=list)
501 + rooms: List[PalaceConsolidationRoomGroup] = Field(default_factory=list)
502 + duplicate_candidates: List[PalaceConsolidationDuplicatePair] = Field(default_factory=list)
503 + # Rendered markdown digest — pre-baked so the drawer can offer a
504 + # "copy as markdown" button without client-side templating.
505 + markdown: str = ""
backend/app/ai_analyst/services/ai_analyst.py
+644
@@ -1,27 +1,50 @@
1 from datetime import datetime
2 +from datetime import timedelta
3 +from difflib import SequenceMatcher
4 from typing import List
5 from typing import Optional
6
7 from fastapi import HTTPException
8 from loguru import logger
9 +from sqlalchemy import case
10 +from sqlalchemy import func
11 from sqlalchemy.ext.asyncio import AsyncSession
12 from sqlalchemy.future import select
13 +from sqlalchemy.orm import selectinload
14
15 from app.ai_analyst.schema.ai_analyst import AlertWithReportResponse
16 from app.ai_analyst.schema.ai_analyst import CreateJobRequest
17 from app.ai_analyst.schema.ai_analyst import CreateJobResponse
18 from app.ai_analyst.schema.ai_analyst import IocResponse
19 +from app.ai_analyst.schema.ai_analyst import IocReviewResponse
20 from app.ai_analyst.schema.ai_analyst import JobResponse
21 +from app.ai_analyst.schema.ai_analyst import MyReviewResponse
22 +from app.ai_analyst.schema.ai_analyst import PalaceConsolidationDuplicatePair
23 +from app.ai_analyst.schema.ai_analyst import PalaceConsolidationLesson
24 +from app.ai_analyst.schema.ai_analyst import PalaceConsolidationResponse
25 +from app.ai_analyst.schema.ai_analyst import PalaceConsolidationRoomGroup
26 +from app.ai_analyst.schema.ai_analyst import PalaceLessonResponse
27 +from app.ai_analyst.schema.ai_analyst import QueuePalaceLessonRequest
28 +from app.ai_analyst.schema.ai_analyst import QueuePalaceLessonResponse
29 from app.ai_analyst.schema.ai_analyst import ReportResponse
30 +from app.ai_analyst.schema.ai_analyst import ReviewResponse
31 +from app.ai_analyst.schema.ai_analyst import ReviewStatsIocAccuracy
32 +from app.ai_analyst.schema.ai_analyst import ReviewStatsResponse
33 +from app.ai_analyst.schema.ai_analyst import ReviewStatsTemplate
34 from app.ai_analyst.schema.ai_analyst import SubmitIocsRequest
35 from app.ai_analyst.schema.ai_analyst import SubmitIocsResponse
36 from app.ai_analyst.schema.ai_analyst import SubmitReportRequest
37 from app.ai_analyst.schema.ai_analyst import SubmitReportResponse
38 +from app.ai_analyst.schema.ai_analyst import SubmitReviewRequest
39 +from app.ai_analyst.schema.ai_analyst import SubmitReviewResponse
40 from app.ai_analyst.schema.ai_analyst import UpdateJobRequest
41 from app.ai_analyst.schema.ai_analyst import UpdateJobResponse
42 from app.db.universal_models import AiAnalystIoc
43 +from app.db.universal_models import AiAnalystIocReview
44 from app.db.universal_models import AiAnalystJob
45 +from app.db.universal_models import AiAnalystPalaceLesson
46 from app.db.universal_models import AiAnalystReport
47 +from app.db.universal_models import AiAnalystReview
48 from app.incidents.models import Alert
49
50
@@ -382,3 +405,624 @@ async def get_alert_analysis(alert_id: int, session: AsyncSession):
405 _report_to_response(report) if report else None,
406 [_ioc_to_response(i) for i in iocs],
407 )
408 +
409 +
410 +# --- Review / Palace lesson helpers ---
411 +
412 +
413 +def _ioc_review_to_response(ir: AiAnalystIocReview) -> IocReviewResponse:
414 + return IocReviewResponse(
415 + id=ir.id,
416 + review_id=ir.review_id,
417 + ioc_id=ir.ioc_id,
418 + verdict_correct=ir.verdict_correct,
419 + note=ir.note,
420 + created_at=ir.created_at,
421 + )
422 +
423 +
424 +def _review_to_response(review: AiAnalystReview) -> ReviewResponse:
425 + return ReviewResponse(
426 + id=review.id,
427 + report_id=review.report_id,
428 + alert_id=review.alert_id,
429 + customer_code=review.customer_code,
430 + reviewer_user_id=review.reviewer_user_id,
431 + overall_verdict=review.overall_verdict,
432 + template_choice=review.template_choice,
433 + template_used=review.template_used,
434 + rating_instructions=review.rating_instructions,
435 + rating_artifacts=review.rating_artifacts,
436 + rating_severity=review.rating_severity,
437 + missing_steps=review.missing_steps,
438 + suggested_edits=review.suggested_edits,
439 + created_at=review.created_at,
440 + updated_at=review.updated_at,
441 + ioc_reviews=[_ioc_review_to_response(ir) for ir in (review.ioc_reviews or [])],
442 + )
443 +
444 +
445 +def _palace_lesson_to_response(lesson: AiAnalystPalaceLesson) -> PalaceLessonResponse:
446 + return PalaceLessonResponse(
447 + id=lesson.id,
448 + review_id=lesson.review_id,
449 + customer_code=lesson.customer_code,
450 + lesson_type=lesson.lesson_type,
451 + lesson_text=lesson.lesson_text,
452 + durability=lesson.durability,
453 + status=lesson.status,
454 + ingested_at=lesson.ingested_at,
455 + created_at=lesson.created_at,
456 + )
457 +
458 +
459 +async def submit_review(
460 + report_id: int,
461 + request: SubmitReviewRequest,
462 + reviewer_user_id: int,
463 + session: AsyncSession,
464 +) -> SubmitReviewResponse:
465 + """
466 + Upsert an analyst review of an AI investigation report.
467 +
468 + Enforces one review per (report_id, reviewer_user_id) pair. If the user
469 + has already reviewed this report, their existing row is updated in place
470 + (and `updated_at` is set) and their per-IOC corrections are replaced
471 + wholesale. Otherwise a new row is inserted.
472 + """
473 + logger.info(f"Submitting review for report {report_id} by user {reviewer_user_id}")
474 +
475 + report = await session.get(AiAnalystReport, report_id)
476 + if not report:
477 + raise HTTPException(status_code=404, detail=f"Report {report_id} not found")
478 +
479 + # If template_used wasn't supplied, inherit from the job for auditability
480 + template_used = request.template_used
481 + if template_used is None:
482 + job = await session.get(AiAnalystJob, report.job_id)
483 + if job is not None:
484 + template_used = job.template_used
485 +
486 + # Validate every referenced IOC before mutating anything. A 400 here means
487 + # we don't half-write and then bail.
488 + for correction in request.ioc_reviews:
489 + ioc = await session.get(AiAnalystIoc, correction.ioc_id)
490 + if ioc is None or ioc.report_id != report_id:
491 + raise HTTPException(
492 + status_code=400,
493 + detail=f"IOC {correction.ioc_id} not found or does not belong to report {report_id}",
494 + )
495 +
496 + # Look up existing review by the unique (report_id, reviewer_user_id) key
497 + existing_result = await session.execute(
498 + select(AiAnalystReview)
499 + .where(AiAnalystReview.report_id == report_id)
500 + .where(AiAnalystReview.reviewer_user_id == reviewer_user_id)
501 + .options(selectinload(AiAnalystReview.ioc_reviews)),
502 + )
503 + review = existing_result.scalars().first()
504 +
505 + is_edit = review is not None
506 +
507 + if is_edit:
508 + # Update existing review in place
509 + review.overall_verdict = request.overall_verdict.value if request.overall_verdict else None
510 + review.template_choice = request.template_choice.value if request.template_choice else None
511 + review.template_used = template_used
512 + review.rating_instructions = request.rating_instructions
513 + review.rating_artifacts = request.rating_artifacts
514 + review.rating_severity = request.rating_severity
515 + review.missing_steps = request.missing_steps
516 + review.suggested_edits = request.suggested_edits
517 + review.updated_at = datetime.utcnow()
518 + session.add(review)
519 +
520 + # Replace per-IOC corrections wholesale — simpler than diffing and the
521 + # edit UI always submits the full set anyway.
522 + for old_ir in list(review.ioc_reviews or []):
523 + await session.delete(old_ir)
524 + await session.flush()
525 + else:
526 + review = AiAnalystReview(
527 + report_id=report_id,
528 + alert_id=report.alert_id,
529 + customer_code=report.customer_code,
530 + reviewer_user_id=reviewer_user_id,
531 + overall_verdict=request.overall_verdict.value if request.overall_verdict else None,
532 + template_choice=request.template_choice.value if request.template_choice else None,
533 + template_used=template_used,
534 + rating_instructions=request.rating_instructions,
535 + rating_artifacts=request.rating_artifacts,
536 + rating_severity=request.rating_severity,
537 + missing_steps=request.missing_steps,
538 + suggested_edits=request.suggested_edits,
539 + created_at=datetime.utcnow(),
540 + )
541 + session.add(review)
542 + await session.flush() # get review.id before inserting child rows
543 +
544 + # Insert the new per-IOC corrections
545 + for correction in request.ioc_reviews:
546 + session.add(
547 + AiAnalystIocReview(
548 + review_id=review.id,
549 + ioc_id=correction.ioc_id,
550 + verdict_correct=correction.verdict_correct,
551 + note=correction.note,
552 + created_at=datetime.utcnow(),
553 + ),
554 + )
555 +
556 + await session.commit()
557 +
558 + # Re-fetch with ioc_reviews eagerly loaded for the response
559 + result = await session.execute(
560 + select(AiAnalystReview).where(AiAnalystReview.id == review.id).options(selectinload(AiAnalystReview.ioc_reviews)),
561 + )
562 + review_loaded = result.scalars().first()
563 +
564 + action = "updated" if is_edit else "created"
565 + logger.info(f"Review {review.id} {action} for report {report_id}")
566 + return SubmitReviewResponse(
567 + success=True,
568 + message=f"Review {action}",
569 + review=_review_to_response(review_loaded),
570 + )
571 +
572 +
573 +async def get_my_review(
574 + report_id: int,
575 + reviewer_user_id: int,
576 + session: AsyncSession,
577 +) -> MyReviewResponse:
578 + """
579 + Look up the current user's existing review for a report.
580 +
581 + Used by the UI to decide whether to render the rubric in 'create' mode or
582 + 'edit existing' mode. Returns success=True with review=None when no review
583 + exists yet (not an error).
584 + """
585 + # Ensure the report itself exists so the UI gets a real 404 for bad ids
586 + report = await session.get(AiAnalystReport, report_id)
587 + if not report:
588 + raise HTTPException(status_code=404, detail=f"Report {report_id} not found")
589 +
590 + result = await session.execute(
591 + select(AiAnalystReview)
592 + .where(AiAnalystReview.report_id == report_id)
593 + .where(AiAnalystReview.reviewer_user_id == reviewer_user_id)
594 + .options(selectinload(AiAnalystReview.ioc_reviews)),
595 + )
596 + review = result.scalars().first()
597 +
598 + if review is None:
599 + return MyReviewResponse(
600 + success=True,
601 + message="No existing review for this user on this report",
602 + review=None,
603 + )
604 +
605 + return MyReviewResponse(
606 + success=True,
607 + message="Existing review retrieved",
608 + review=_review_to_response(review),
609 + )
610 +
611 +
612 +async def queue_palace_lesson(
613 + request: QueuePalaceLessonRequest,
614 + session: AsyncSession,
615 +) -> QueuePalaceLessonResponse:
616 + """
617 + Queue a MemPalace lesson for async drainer pickup. Does NOT call Talon
618 + directly — the drainer (roadmap item 17) reads status='pending' rows and
619 + POSTs to NanoClaw's /palace/lesson endpoint.
620 + """
621 + logger.info(f"Queuing palace lesson for {request.customer_code})")
622 +
623 + # If review_id supplied, validate it exists
624 + if request.review_id is not None:
625 + review = await session.get(AiAnalystReview, request.review_id)
626 + if review is None:
627 + raise HTTPException(
628 + status_code=404,
629 + detail=f"Review {request.review_id} not found",
630 + )
631 +
632 + lesson = AiAnalystPalaceLesson(
633 + review_id=request.review_id,
634 + customer_code=request.customer_code,
635 + lesson_type=request.lesson_type.value,
636 + lesson_text=request.lesson_text,
637 + durability=request.durability.value,
638 + status="pending",
639 + created_at=datetime.utcnow(),
640 + )
641 + session.add(lesson)
642 + await session.commit()
643 + await session.refresh(lesson)
644 +
645 + logger.info(f"Palace lesson {lesson.id} queued (status=pending)")
646 + return QueuePalaceLessonResponse(
647 + success=True,
648 + message="Palace lesson queued for ingestion",
649 + lesson=_palace_lesson_to_response(lesson),
650 + )
651 +
652 +
653 +async def list_reviews_by_customer(
654 + customer_code: str,
655 + session: AsyncSession,
656 +) -> List[ReviewResponse]:
657 + """Dashboard feed — reviews for a customer, newest first, with nested IOC reviews."""
658 + result = await session.execute(
659 + select(AiAnalystReview)
660 + .where(AiAnalystReview.customer_code == customer_code)
661 + .options(selectinload(AiAnalystReview.ioc_reviews))
662 + .order_by(AiAnalystReview.created_at.desc()),
663 + )
664 + reviews = result.scalars().all()
665 + return [_review_to_response(r) for r in reviews]
666 +
667 +
668 +def _pct(numerator: int, denominator: int) -> Optional[float]:
669 + """Percentage helper — None when the denominator is 0 so the UI can
670 + render a dash instead of a misleading '0%'."""
671 + if denominator <= 0:
672 + return None
673 + return round((numerator / denominator) * 100, 2)
674 +
675 +
676 +def _round_float(v) -> Optional[float]:
677 + """Avg helper — SQLAlchemy returns Decimal on some dialects; normalize to
678 + a 2-decimal float for JSON. Returns None if the source was NULL (no rows)."""
679 + if v is None:
680 + return None
681 + return round(float(v), 2)
682 +
683 +
684 +async def get_review_stats(
685 + customer_code: str,
686 + session: AsyncSession,
687 + recent_limit: int = 10,
688 +) -> ReviewStatsResponse:
689 + """Aggregate feedback metrics for the customer's review dashboard.
690 +
691 + Uses SQL-side COUNT/AVG/CASE aggregates so this scales with review count
692 + rather than pulling every row through Python — per the scale-first
693 + design call on Step 20.
694 + """
695 + # Main rollup: totals, verdict counts, template-choice counts, avg ratings.
696 + main_q = select(
697 + func.count(AiAnalystReview.id).label("total"),
698 + func.sum(case((AiAnalystReview.overall_verdict == "up", 1), else_=0)).label("thumbs_up"),
699 + func.sum(case((AiAnalystReview.overall_verdict == "down", 1), else_=0)).label("thumbs_down"),
700 + func.sum(case((AiAnalystReview.template_choice == "correct", 1), else_=0)).label("tpl_correct"),
701 + func.sum(case((AiAnalystReview.template_choice == "partial", 1), else_=0)).label("tpl_partial"),
702 + func.sum(case((AiAnalystReview.template_choice == "wrong", 1), else_=0)).label("tpl_wrong"),
703 + func.avg(AiAnalystReview.rating_instructions).label("avg_instr"),
704 + func.avg(AiAnalystReview.rating_artifacts).label("avg_artifacts"),
705 + func.avg(AiAnalystReview.rating_severity).label("avg_severity"),
706 + ).where(AiAnalystReview.customer_code == customer_code)
707 + main_row = (await session.execute(main_q)).one()
708 +
709 + total = int(main_row.total or 0)
710 + thumbs_up = int(main_row.thumbs_up or 0)
711 + thumbs_down = int(main_row.thumbs_down or 0)
712 + # Non-null denominator for the up% gauge — reviews that actually picked a
713 + # thumb. Skips reviews that left overall_verdict null.
714 + verdict_total = thumbs_up + thumbs_down
715 +
716 + # Per-template rollup, grouped by template_used (which may be NULL).
717 + per_template_q = (
718 + select(
719 + AiAnalystReview.template_used.label("template_used"),
720 + func.count(AiAnalystReview.id).label("total"),
721 + func.sum(case((AiAnalystReview.overall_verdict == "up", 1), else_=0)).label("thumbs_up"),
722 + func.sum(case((AiAnalystReview.overall_verdict == "down", 1), else_=0)).label("thumbs_down"),
723 + func.sum(case((AiAnalystReview.template_choice == "correct", 1), else_=0)).label("correct"),
724 + func.sum(case((AiAnalystReview.template_choice == "partial", 1), else_=0)).label("partial"),
725 + func.sum(case((AiAnalystReview.template_choice == "wrong", 1), else_=0)).label("wrong"),
726 + func.avg(AiAnalystReview.rating_instructions).label("avg_instr"),
727 + func.avg(AiAnalystReview.rating_artifacts).label("avg_artifacts"),
728 + func.avg(AiAnalystReview.rating_severity).label("avg_severity"),
729 + )
730 + .where(AiAnalystReview.customer_code == customer_code)
731 + .group_by(AiAnalystReview.template_used)
732 + .order_by(func.count(AiAnalystReview.id).desc())
733 + )
734 + per_template_rows = (await session.execute(per_template_q)).all()
735 +
736 + # IOC verdict accuracy — join IocReview rows back to the customer's reviews
737 + # so we only count corrections attached to this customer's reports.
738 + ioc_q = (
739 + select(
740 + func.count(AiAnalystIocReview.id).label("total"),
741 + func.sum(case((AiAnalystIocReview.verdict_correct.is_(True), 1), else_=0)).label("correct"),
742 + func.sum(case((AiAnalystIocReview.verdict_correct.is_(False), 1), else_=0)).label("incorrect"),
743 + )
744 + .join(AiAnalystReview, AiAnalystReview.id == AiAnalystIocReview.review_id)
745 + .where(AiAnalystReview.customer_code == customer_code)
746 + )
747 + ioc_row = (await session.execute(ioc_q)).one()
748 + ioc_total = int(ioc_row.total or 0)
749 + ioc_correct = int(ioc_row.correct or 0)
750 + ioc_incorrect = int(ioc_row.incorrect or 0)
751 +
752 + # Recent reviews (hydrated with ioc_reviews for drill-in).
753 + recent_q = (
754 + select(AiAnalystReview)
755 + .where(AiAnalystReview.customer_code == customer_code)
756 + .options(selectinload(AiAnalystReview.ioc_reviews))
757 + .order_by(AiAnalystReview.created_at.desc())
758 + .limit(recent_limit)
759 + )
760 + recent_reviews = (await session.execute(recent_q)).scalars().all()
761 +
762 + per_template: List[ReviewStatsTemplate] = [
763 + ReviewStatsTemplate(
764 + template_used=row.template_used,
765 + total=int(row.total or 0),
766 + thumbs_up=int(row.thumbs_up or 0),
767 + thumbs_down=int(row.thumbs_down or 0),
768 + correct=int(row.correct or 0),
769 + partial=int(row.partial or 0),
770 + wrong=int(row.wrong or 0),
771 + avg_rating_instructions=_round_float(row.avg_instr),
772 + avg_rating_artifacts=_round_float(row.avg_artifacts),
773 + avg_rating_severity=_round_float(row.avg_severity),
774 + )
775 + for row in per_template_rows
776 + ]
777 +
778 + return ReviewStatsResponse(
779 + success=True,
780 + message=f"Review stats for {customer_code}",
781 + customer_code=customer_code,
782 + total_reviews=total,
783 + thumbs_up=thumbs_up,
784 + thumbs_down=thumbs_down,
785 + thumbs_up_pct=_pct(thumbs_up, verdict_total),
786 + template_choice_correct=int(main_row.tpl_correct or 0),
787 + template_choice_partial=int(main_row.tpl_partial or 0),
788 + template_choice_wrong=int(main_row.tpl_wrong or 0),
789 + avg_rating_instructions=_round_float(main_row.avg_instr),
790 + avg_rating_artifacts=_round_float(main_row.avg_artifacts),
791 + avg_rating_severity=_round_float(main_row.avg_severity),
792 + ioc_accuracy=ReviewStatsIocAccuracy(
793 + total=ioc_total,
794 + correct=ioc_correct,
795 + incorrect=ioc_incorrect,
796 + accuracy_pct=_pct(ioc_correct, ioc_total),
797 + ),
798 + per_template=per_template,
799 + recent_reviews=[_review_to_response(r) for r in recent_reviews],
800 + )
801 +
802 +
803 +# --- Palace consolidation (Step 21.B) ---
804 +
805 +# Keep in sync with invoke_palace_lesson_sweeper.ONE_OFF_EXPIRY_DAYS —
806 +# duplicated here rather than imported to avoid pulling a scheduler
807 +# service into the synchronous route path at import time.
808 +_ONE_OFF_EXPIRY_DAYS = 7
809 +# Lessons within this many days of expiring are surfaced to the reviewer
810 +# as "about to be swept" — gives a window to promote to durable.
811 +_EXPIRY_SOON_WINDOW_DAYS = 2
812 +# Similarity threshold for near-duplicate detection. 0.70 picks up
813 +# paraphrases without flooding the reviewer with every shared phrase.
814 +# difflib's SequenceMatcher on short strings is cheap — we can afford
815 +# O(n²) pairs per room.
816 +_DUPLICATE_SIMILARITY_THRESHOLD = 0.70
817 +
818 +
819 +def _lesson_to_consolidation(
820 + lesson: AiAnalystPalaceLesson,
821 + now: datetime,
822 +) -> PalaceConsolidationLesson:
823 + days_until_expiry: Optional[int] = None
824 + if lesson.durability == "one_off" and lesson.ingested_at is not None:
825 + expiry = lesson.ingested_at + timedelta(days=_ONE_OFF_EXPIRY_DAYS)
826 + days_until_expiry = (expiry - now).days
827 + return PalaceConsolidationLesson(
828 + id=lesson.id,
829 + lesson_type=lesson.lesson_type,
830 + lesson_text=lesson.lesson_text,
831 + durability=lesson.durability,
832 + status=lesson.status,
833 + drawer_id=lesson.drawer_id,
834 + created_at=lesson.created_at,
835 + ingested_at=lesson.ingested_at,
836 + days_until_expiry=days_until_expiry,
837 + )
838 +
839 +
840 +def _find_duplicate_pairs(
841 + lessons: List[PalaceConsolidationLesson],
842 +) -> List[PalaceConsolidationDuplicatePair]:
843 + """Pairwise SequenceMatcher within the same room. Only returns
844 + pairs above the threshold, sorted by similarity descending."""
845 + pairs: List[PalaceConsolidationDuplicatePair] = []
846 + # Group by room first so we only compare within-room.
847 + by_room: dict[str, List[PalaceConsolidationLesson]] = {}
848 + for lesson in lessons:
849 + by_room.setdefault(lesson.lesson_type, []).append(lesson)
850 +
851 + for room, room_lessons in by_room.items():
852 + # Normalize once up-front so SequenceMatcher has stable inputs.
853 + normalized = [(ls, ls.lesson_text.strip().lower()) for ls in room_lessons]
854 + for i in range(len(normalized)):
855 + a_lesson, a_text = normalized[i]
856 + if not a_text:
857 + continue
858 + for j in range(i + 1, len(normalized)):
859 + b_lesson, b_text = normalized[j]
860 + if not b_text:
861 + continue
862 + ratio = SequenceMatcher(None, a_text, b_text).ratio()
863 + if ratio >= _DUPLICATE_SIMILARITY_THRESHOLD:
864 + pairs.append(
865 + PalaceConsolidationDuplicatePair(
866 + room=room,
867 + lesson_a_id=a_lesson.id,
868 + lesson_b_id=b_lesson.id,
869 + lesson_a_text=a_lesson.lesson_text,
870 + lesson_b_text=b_lesson.lesson_text,
871 + similarity=round(ratio, 3),
872 + ),
873 + )
874 + pairs.sort(key=lambda p: p.similarity, reverse=True)
875 + return pairs
876 +
877 +
878 +def _render_consolidation_markdown(
879 + customer_code: str,
880 + generated_at: datetime,
881 + total_lessons: int,
882 + total_durable: int,
883 + total_one_off: int,
884 + rooms: List[PalaceConsolidationRoomGroup],
885 + duplicates: List[PalaceConsolidationDuplicatePair],
886 + upcoming: List[PalaceConsolidationLesson],
887 +) -> str:
888 + """Pre-render the digest as markdown so the drawer can offer a
889 + one-click copy/export. Kept intentionally terse — headings + bullets."""
890 + lines: List[str] = []
891 + lines.append(f"# Palace consolidation — {customer_code}")
892 + lines.append(f"_Generated {generated_at.isoformat()} UTC_")
893 + lines.append("")
894 + lines.append("## Summary")
895 + lines.append(f"- Total active lessons: **{total_lessons}**")
896 + lines.append(f"- Durable: {total_durable} | One-off: {total_one_off}")
897 + if upcoming:
898 + lines.append(
899 + f"- **{len(upcoming)} one-off lesson(s) expiring within "
900 + f"{_EXPIRY_SOON_WINDOW_DAYS} day(s)** — consider promoting to durable.",
901 + )
902 + if duplicates:
903 + lines.append(f"- **{len(duplicates)} near-duplicate pair(s)** flagged for review.")
904 + lines.append("")
905 +
906 + if upcoming:
907 + lines.append("## Upcoming expirations")
908 + for ls in upcoming:
909 + due = ls.days_until_expiry if ls.days_until_expiry is not None else "?"
910 + lines.append(f"- _{ls.lesson_type}_ (id {ls.id}, in {due}d): {ls.lesson_text}")
911 + lines.append("")
912 +
913 + if duplicates:
914 + lines.append("## Near-duplicate candidates")
915 + for pair in duplicates:
916 + pct = int(pair.similarity * 100)
917 + lines.append(f"- **{pair.room}** — {pct}% similar")
918 + lines.append(f" - #{pair.lesson_a_id}: {pair.lesson_a_text}")
919 + lines.append(f" - #{pair.lesson_b_id}: {pair.lesson_b_text}")
920 + lines.append("")
921 +
922 + lines.append("## Rooms")
923 + for group in rooms:
924 + lines.append(
925 + f"### {group.room} ({group.total} total — " f"{group.durable} durable, {group.one_off} one-off)",
926 + )
927 + for ls in group.lessons:
928 + tag = "🧷" if ls.durability == "durable" else "⏳"
929 + suffix = ""
930 + if ls.durability == "one_off" and ls.days_until_expiry is not None:
931 + suffix = f" _(expires in {ls.days_until_expiry}d)_"
932 + lines.append(f"- {tag} #{ls.id}{suffix}: {ls.lesson_text}")
933 + lines.append("")
934 +
935 + return "\n".join(lines).rstrip() + "\n"
936 +
937 +
938 +async def get_palace_consolidation(
939 + customer_code: str,
940 + session: AsyncSession,
941 +) -> PalaceConsolidationResponse:
942 + """Build a point-in-time digest of a customer's active MemPalace
943 + lessons. Pure read-only, pure Python — no Talon round-trip. Used by
944 + the manual "Consolidate Lessons" button in the Feedback dashboard."""
945 + logger.info(f"Building palace consolidation digest for customer {customer_code}")
946 +
947 + # Exclude expired (already swept) and failed (never reached the palace)
948 + # rows — consolidation is about what's actually live right now.
949 + stmt = (
950 + select(AiAnalystPalaceLesson)
951 + .where(AiAnalystPalaceLesson.customer_code == customer_code)
952 + .where(AiAnalystPalaceLesson.status.in_(["pending", "ingested"]))
953 + .order_by(
954 + AiAnalystPalaceLesson.lesson_type.asc(),
955 + AiAnalystPalaceLesson.created_at.desc(),
956 + )
957 + )
958 + result = await session.execute(stmt)
959 + raw_lessons = result.scalars().all()
960 +
961 + now = datetime.utcnow()
962 + lessons = [_lesson_to_consolidation(ls, now) for ls in raw_lessons]
963 +
964 + total_lessons = len(lessons)
965 + total_durable = sum(1 for ls in lessons if ls.durability == "durable")
966 + total_one_off = sum(1 for ls in lessons if ls.durability == "one_off")
967 + total_pending = sum(1 for ls in lessons if ls.status == "pending")
968 + total_ingested = sum(1 for ls in lessons if ls.status == "ingested")
969 +
970 + upcoming = sorted(
971 + [
972 + ls
973 + for ls in lessons
974 + if ls.durability == "one_off" and ls.days_until_expiry is not None and ls.days_until_expiry <= _EXPIRY_SOON_WINDOW_DAYS
975 + ],
976 + key=lambda ls: ls.days_until_expiry if ls.days_until_expiry is not None else 0,
977 + )
978 +
979 + # Build per-room groups in deterministic room order.
980 + by_room: dict[str, List[PalaceConsolidationLesson]] = {}
981 + for ls in lessons:
982 + by_room.setdefault(ls.lesson_type, []).append(ls)
983 + rooms: List[PalaceConsolidationRoomGroup] = []
984 + for room in sorted(by_room.keys()):
985 + room_lessons = by_room[room]
986 + rooms.append(
987 + PalaceConsolidationRoomGroup(
988 + room=room,
989 + total=len(room_lessons),
990 + durable=sum(1 for ls in room_lessons if ls.durability == "durable"),
991 + one_off=sum(1 for ls in room_lessons if ls.durability == "one_off"),
992 + lessons=room_lessons,
993 + ),
994 + )
995 +
996 + duplicates = _find_duplicate_pairs(lessons)
997 +
998 + markdown = _render_consolidation_markdown(
999 + customer_code=customer_code,
1000 + generated_at=now,
1001 + total_lessons=total_lessons,
1002 + total_durable=total_durable,
1003 + total_one_off=total_one_off,
1004 + rooms=rooms,
1005 + duplicates=duplicates,
1006 + upcoming=upcoming,
1007 + )
1008 +
1009 + return PalaceConsolidationResponse(
1010 + success=True,
1011 + message=(
1012 + f"Palace consolidation for {customer_code}: "
1013 + f"{total_lessons} active lesson(s), "
1014 + f"{len(duplicates)} duplicate pair(s), "
1015 + f"{len(upcoming)} expiring soon"
1016 + ),
1017 + customer_code=customer_code,
1018 + generated_at=now,
1019 + total_lessons=total_lessons,
1020 + total_durable=total_durable,
1021 + total_one_off=total_one_off,
1022 + total_pending=total_pending,
1023 + total_ingested=total_ingested,
1024 + upcoming_expirations=upcoming,
1025 + rooms=rooms,
1026 + duplicate_candidates=duplicates,
1027 + markdown=markdown,
1028 + )
backend/app/connectors/talon/routes/talon.py
+14
@@ -11,9 +11,11 @@ from app.connectors.talon.schema.talon import TalonInvestigateResponse
11 from app.connectors.talon.schema.talon import TalonJobResponse
12 from app.connectors.talon.schema.talon import TalonMessageRequest
13 from app.connectors.talon.schema.talon import TalonStatusResponse
14 +from app.connectors.talon.schema.talon import TalonTemplatesResponse
15 from app.connectors.talon.services.talon import get_talon_job
16 from app.connectors.talon.services.talon import get_talon_status
17 from app.connectors.talon.services.talon import investigate_alert
18 +from app.connectors.talon.services.talon import list_talon_templates
19 from app.connectors.talon.services.talon import stream_talon_message
20 from app.db.db_session import get_db
21
@@ -64,6 +66,18 @@ async def get_status() -> TalonStatusResponse:
66 return await get_talon_status()
67
68
69 +@talon_router.get(
70 + "/templates",
71 + response_model=TalonTemplatesResponse,
72 + description="List the prompt templates available in NanoClaw's CoPilot group (for replay picker)",
73 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
74 +)
75 +async def get_templates() -> TalonTemplatesResponse:
76 + """Proxy NanoClaw GET /templates — read-only template metadata, no bodies."""
77 + logger.info("Fetching Talon templates list")
78 + return await list_talon_templates()
79 +
80 +
81 @talon_router.get(
82 "/jobs/{alert_id}",
83 response_model=TalonJobResponse,
backend/app/connectors/talon/schema/talon.py
+14
@@ -1,5 +1,6 @@
1 from typing import Any
2 from typing import Dict
3 +from typing import List
4 from typing import Optional
5
6 from pydantic import BaseModel
@@ -39,3 +40,16 @@ class TalonJobResponse(BaseModel):
40 success: bool
41 message: str
42 data: Optional[Dict[str, Any]] = None
43 +
44 +
45 +class TalonTemplate(BaseModel):
46 + filename: str = Field(..., description="Template filename, e.g. sysmon_event_1.txt")
47 + size_bytes: int = Field(..., description="File size in bytes")
48 + modified_at: str = Field(..., description="Last modification ISO timestamp")
49 + first_line: Optional[str] = Field(None, description="First non-empty line (preview, ≤200 chars)")
50 +
51 +
52 +class TalonTemplatesResponse(BaseModel):
53 + success: bool
54 + message: str
55 + templates: List[TalonTemplate] = Field(default_factory=list)
backend/app/connectors/talon/services/talon.py
+108
@@ -1,3 +1,7 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import Optional
4 +
5 from fastapi import HTTPException
6 from loguru import logger
7 from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +14,7 @@ from app.connectors.talon.schema.talon import TalonJobResponse
14 from app.connectors.talon.schema.talon import TalonMessageRequest
15 from app.connectors.talon.schema.talon import TalonMessageResponse
16 from app.connectors.talon.schema.talon import TalonStatusResponse
17 +from app.connectors.talon.schema.talon import TalonTemplatesResponse
18 from app.connectors.talon.utils.universal import send_get_request
19 from app.connectors.talon.utils.universal import send_post_request
20 from app.connectors.talon.utils.universal import send_post_request_sse
@@ -171,3 +176,106 @@ async def get_talon_job(alert_id: int, session: AsyncSession) -> TalonJobRespons
176 "reports": all_reports,
177 },
178 )
179 +
180 +
181 +async def replay_investigation(
182 + alert_id: int,
183 + customer_code: str,
184 + template_override: str,
185 + sender: str = "copilot-replay",
186 +) -> Dict[str, Any]:
187 + """
188 + Trigger an investigation replay with a forced template via Talon's
189 + POST /investigate endpoint.
190 +
191 + Args:
192 + alert_id: CoPilot alert ID to re-investigate.
193 + customer_code: Customer code for the alert.
194 + template_override: Template filename to force (validated upstream).
195 + sender: Audit identifier for the replay.
196 +
197 + Returns:
198 + Raw Talon response envelope (success, message, data).
199 + """
200 + logger.info(
201 + f"Replaying Talon investigation for alert {alert_id} " f"with template_override={template_override}",
202 + )
203 + response = await send_post_request(
204 + endpoint="/investigate",
205 + data={
206 + "alert_id": alert_id,
207 + "customer_code": customer_code,
208 + "template_override": template_override,
209 + "sender": sender,
210 + },
211 + )
212 + if not response.get("success"):
213 + raise HTTPException(
214 + status_code=500,
215 + detail=response.get("message", "Failed to replay Talon investigation"),
216 + )
217 + return response
218 +
219 +
220 +async def list_talon_templates() -> TalonTemplatesResponse:
221 + """
222 + List the prompt templates available in NanoClaw's CoPilot group.
223 + Powers the "Re-run with different template" picker in the review UI.
224 +
225 + NanoClaw returns {templates: [{filename, size_bytes, modified_at, first_line}]}.
226 + We surface that envelope directly — template bodies stay server-side.
227 + """
228 + logger.info("Fetching Talon templates list")
229 + response = await send_get_request(endpoint="/templates")
230 + if not response.get("success"):
231 + raise HTTPException(
232 + status_code=500,
233 + detail=response.get("message", "Failed to list Talon templates"),
234 + )
235 + data = response.get("data") or {}
236 + raw_templates = data.get("templates") if isinstance(data, dict) else None
237 + if raw_templates is None:
238 + raw_templates = []
239 + return TalonTemplatesResponse(
240 + success=True,
241 + message=f"{len(raw_templates)} templates retrieved",
242 + templates=raw_templates,
243 + )
244 +
245 +
246 +async def search_palace_lessons(
247 + customer_code: str,
248 + query: str,
249 + room: Optional[str] = None,
250 + limit: int = 5,
251 +) -> Dict[str, Any]:
252 + """
253 + Preview similar MemPalace lessons via Talon's GET /palace/search endpoint.
254 + Read-only — never mutates the palace.
255 +
256 + Args:
257 + customer_code: Customer whose wing to search.
258 + query: Semantic search query.
259 + room: Optional room filter (environment, false_positives, assets, threat_intel, alerts).
260 + limit: Max hits to return (clamped 1–25 upstream).
261 +
262 + Returns:
263 + Raw Talon response envelope (success, message, data).
264 + """
265 + logger.info(
266 + f"Searching MemPalace for customer={customer_code} room={room} query={query!r} limit={limit}",
267 + )
268 + params: Dict[str, Any] = {
269 + "customer_code": customer_code,
270 + "query": query,
271 + "limit": limit,
272 + }
273 + if room:
274 + params["room"] = room
275 + response = await send_get_request(endpoint="/palace/search", params=params)
276 + if not response.get("success"):
277 + raise HTTPException(
278 + status_code=500,
279 + detail=response.get("message", "Failed to search MemPalace"),
280 + )
281 + return response
backend/app/db/universal_models.py
+73
@@ -624,3 +624,76 @@ class AiAnalystIoc(SQLModel, table=True):
624
625 report: Optional["AiAnalystReport"] = Relationship(back_populates="iocs")
626 customer: Optional["Customers"] = Relationship()
627 + ioc_reviews: list["AiAnalystIocReview"] = Relationship(back_populates="ioc")
628 +
629 +
630 +class AiAnalystReview(SQLModel, table=True):
631 + __tablename__ = "ai_analyst_review"
632 + __table_args__ = (
633 + UniqueConstraint(
634 + "report_id",
635 + "reviewer_user_id",
636 + name="uq_ai_analyst_review_report_reviewer",
637 + ),
638 + )
639 +
640 + id: Optional[int] = Field(primary_key=True)
641 + report_id: int = Field(foreign_key="ai_analyst_report.id", nullable=False, index=True)
642 + alert_id: int = Field(nullable=False, index=True)
643 + customer_code: str = Field(foreign_key="customers.customer_code", max_length=64, index=True, nullable=False)
644 + reviewer_user_id: int = Field(nullable=False, index=True)
645 + overall_verdict: Optional[str] = Field(default=None, max_length=4) # up, down
646 + template_choice: Optional[str] = Field(default=None, max_length=7) # correct, wrong, partial
647 + template_used: Optional[str] = Field(default=None, max_length=128)
648 + rating_instructions: Optional[int] = Field(default=None) # 1–5
649 + rating_artifacts: Optional[int] = Field(default=None) # 1–5
650 + rating_severity: Optional[int] = Field(default=None) # 1–5
651 + missing_steps: Optional[str] = Field(sa_column=Column(Text), default=None)
652 + suggested_edits: Optional[str] = Field(sa_column=Column(Text), default=None)
653 + created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
654 + updated_at: Optional[datetime] = Field(default=None)
655 +
656 + report: Optional["AiAnalystReport"] = Relationship()
657 + customer: Optional["Customers"] = Relationship()
658 + ioc_reviews: list["AiAnalystIocReview"] = Relationship(back_populates="review")
659 + palace_lessons: list["AiAnalystPalaceLesson"] = Relationship(back_populates="review")
660 +
661 +
662 +class AiAnalystIocReview(SQLModel, table=True):
663 + __tablename__ = "ai_analyst_ioc_review"
664 +
665 + id: Optional[int] = Field(primary_key=True)
666 + review_id: int = Field(foreign_key="ai_analyst_review.id", nullable=False, index=True)
667 + ioc_id: int = Field(foreign_key="ai_analyst_ioc.id", nullable=False, index=True)
668 + verdict_correct: bool = Field(nullable=False)
669 + note: Optional[str] = Field(sa_column=Column(Text), default=None)
670 + created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
671 +
672 + review: Optional["AiAnalystReview"] = Relationship(back_populates="ioc_reviews")
673 + ioc: Optional["AiAnalystIoc"] = Relationship(back_populates="ioc_reviews")
674 +
675 +
676 +class AiAnalystPalaceLesson(SQLModel, table=True):
677 + __tablename__ = "ai_analyst_palace_lesson"
678 +
679 + id: Optional[int] = Field(primary_key=True)
680 + review_id: Optional[int] = Field(foreign_key="ai_analyst_review.id", default=None, index=True) # nullable — can be standalone
681 + customer_code: str = Field(foreign_key="customers.customer_code", max_length=64, index=True, nullable=False)
682 + lesson_type: str = Field(max_length=20, nullable=False) # environment, false_positives, assets, threat_intel
683 + lesson_text: str = Field(sa_column=Column(Text), nullable=False)
684 + durability: str = Field(default="durable", max_length=8) # one_off, durable
685 + status: str = Field(default="pending", max_length=8, index=True) # pending, ingested, failed, expired
686 + # drawer_id returned by mempalace add_drawer — required to call
687 + # delete_drawer later when the durability sweeper expires one-offs.
688 + # Nullable because legacy rows predate this column and because the
689 + # drainer may fail to capture it if NanoClaw returns a malformed body.
690 + drawer_id: Optional[str] = Field(default=None, max_length=64, index=True)
691 + ingested_at: Optional[datetime] = Field(default=None)
692 + # Timestamp of the sweeper's delete_drawer call. Set when status flips
693 + # from 'ingested' → 'expired' so audit queries can tell "never swept"
694 + # apart from "swept but failed".
695 + expired_at: Optional[datetime] = Field(default=None)
696 + created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
697 +
698 + review: Optional["AiAnalystReview"] = Relationship(back_populates="palace_lessons")
699 + customer: Optional["Customers"] = Relationship()
backend/app/schedulers/scheduler.py
+20
@@ -26,6 +26,12 @@ from app.schedulers.services.invoke_duo import invoke_duo_integration_collect
26 from app.schedulers.services.invoke_huntress import invoke_huntress_integration_collect
27 from app.schedulers.services.invoke_mimecast import invoke_mimecast_integration
28 from app.schedulers.services.invoke_mimecast import invoke_mimecast_integration_ttp
29 +from app.schedulers.services.invoke_palace_lesson_drainer import (
30 + invoke_palace_lesson_drainer,
31 +)
32 +from app.schedulers.services.invoke_palace_lesson_sweeper import (
33 + invoke_palace_lesson_sweeper,
34 +)
35 from app.schedulers.services.invoke_sap_siem import (
36 invoke_sap_siem_integration_brute_force_failed_logins,
37 )
@@ -154,6 +160,18 @@ async def initialize_job_metadata():
160 "function": invoke_snapshot_schedules,
161 "description": "Invokes Index snapshot schedules execution.",
162 },
163 + {
164 + "job_id": "invoke_palace_lesson_drainer",
165 + "time_interval": 2,
166 + "function": invoke_palace_lesson_drainer,
167 + "description": "Drains pending AI analyst palace lessons into MemPalace via NanoClaw /palace/lesson.",
168 + },
169 + {
170 + "job_id": "invoke_palace_lesson_sweeper",
171 + "time_interval": 60,
172 + "function": invoke_palace_lesson_sweeper,
173 + "description": "Forgets expired one-off AI analyst palace lessons via NanoClaw /palace/forget.",
174 + },
175 # ! Mirgrated SIGMA to VELO ! #
176 # {
177 # "job_id": "invoke_sigma_queries_collect",
@@ -284,6 +302,8 @@ def get_function_by_name(function_name: str):
302 "invoke_duo_integration_collect": invoke_duo_integration_collect,
303 "invoke_darktrace_integration_collect": invoke_darktrace_integration_collect,
304 "invoke_carbonblack_integration_collection": invoke_carbonblack_integration_collect,
305 + "invoke_palace_lesson_drainer": invoke_palace_lesson_drainer,
306 + "invoke_palace_lesson_sweeper": invoke_palace_lesson_sweeper,
307 # Add other function mappings here
308 }
309 return function_map.get(
backend/app/schedulers/services/invoke_palace_lesson_drainer.py new
+137
@@ -0,0 +1,137 @@
1 +"""
2 +Palace lesson drainer — step 17 of the CoPilot ↔ NanoClaw Talon integration.
3 +
4 +Polls the `ai_analyst_palace_lesson` table for rows with status='pending' and
5 +POSTs each one to NanoClaw's `/palace/lesson` endpoint (which wraps the
6 +MemPalace `add_drawer` MCP tool). On success the row is marked 'ingested'
7 +with an `ingested_at` timestamp; on any failure the row is marked 'failed'
8 +and left alone — no automatic retry. The teach-the-palace UI surfaces
9 +failures so an operator can manually requeue.
10 +
11 +Scheduling: runs every 2 minutes via APScheduler. Batch size is capped to
12 +prevent a large backlog from locking the scheduler tick.
13 +"""
14 +from datetime import datetime
15 +
16 +from loguru import logger
17 +from sqlalchemy.future import select
18 +
19 +from app.connectors.talon.utils.universal import send_post_request
20 +from app.db.db_session import get_db_session
21 +from app.db.universal_models import AiAnalystPalaceLesson
22 +from app.schedulers.models.scheduler import JobMetadata
23 +
24 +JOB_ID = "invoke_palace_lesson_drainer"
25 +
26 +# How many pending lessons to process per scheduler tick. A large backlog
27 +# gets drained over multiple ticks rather than hogging the event loop.
28 +DEFAULT_BATCH_SIZE = 25
29 +
30 +
31 +async def invoke_palace_lesson_drainer() -> None:
32 + """
33 + Drain one batch of pending palace lessons to NanoClaw.
34 +
35 + Returns silently (logging only) in these cases:
36 + - No pending lessons exist.
37 + - Talon connector is not configured in the DB.
38 + - The HTTP POST raises or returns success=False (row → 'failed').
39 +
40 + Exceptions here must not propagate up into APScheduler — the EVENT_JOB_ERROR
41 + listener in scheduler.py would log a crash, and the job would continue on
42 + its interval anyway.
43 + """
44 + logger.info("Palace lesson drainer tick")
45 +
46 + async with get_db_session() as session:
47 + # Pull oldest pending lessons first; cap the batch
48 + stmt = (
49 + select(AiAnalystPalaceLesson)
50 + .where(AiAnalystPalaceLesson.status == "pending")
51 + .order_by(AiAnalystPalaceLesson.created_at.asc())
52 + .limit(DEFAULT_BATCH_SIZE)
53 + )
54 + result = await session.execute(stmt)
55 + lessons = result.scalars().all()
56 +
57 + if not lessons:
58 + logger.debug("No pending palace lessons to drain")
59 + await _mark_job_success(session)
60 + return
61 +
62 + logger.info(f"Draining {len(lessons)} pending palace lesson(s) to NanoClaw")
63 +
64 + ingested = 0
65 + failed = 0
66 +
67 + for lesson in lessons:
68 + payload = {
69 + "customer_code": lesson.customer_code,
70 + "lesson_type": lesson.lesson_type,
71 + "lesson_text": lesson.lesson_text,
72 + "durability": lesson.durability,
73 + }
74 +
75 + try:
76 + # send_post_request handles connector lookup + auth headers +
77 + # error trapping. It never raises; it returns {success, ...}.
78 + response = await send_post_request(
79 + endpoint="/palace/lesson",
80 + data=payload,
81 + timeout=60,
82 + )
83 + except Exception as e:
84 + # Defensive — shouldn't happen because send_post_request
85 + # already wraps its own exceptions, but we don't want one
86 + # bad lesson to break the whole batch.
87 + logger.error(f"Unexpected error posting palace lesson {lesson.id}: {e}")
88 + response = {"success": False, "message": str(e)}
89 +
90 + if response.get("success"):
91 + lesson.status = "ingested"
92 + lesson.ingested_at = datetime.utcnow()
93 + # Capture drawer_id so the durability sweeper can later
94 + # call /palace/forget for expired one-off lessons.
95 + # send_post_request wraps the raw NanoClaw body under
96 + # response["data"]; mempalace's tool_add_drawer places
97 + # drawer_id at the top of its return dict.
98 + body = response.get("data") if isinstance(response.get("data"), dict) else {}
99 + drawer_id = body.get("drawer_id")
100 + if isinstance(drawer_id, str) and drawer_id:
101 + lesson.drawer_id = drawer_id
102 + else:
103 + logger.warning(
104 + f"Palace lesson {lesson.id} ingested without drawer_id in response "
105 + f"(body_keys={list(body.keys()) if body else []}); sweeper will skip this row",
106 + )
107 + ingested += 1
108 + logger.info(f"Lesson {lesson.id} ingested (customer={lesson.customer_code}, type={lesson.lesson_type})")
109 + else:
110 + lesson.status = "failed"
111 + failed += 1
112 + logger.warning(
113 + f"Palace lesson {lesson.id} failed: " f"{response.get('message', 'unknown error')}",
114 + )
115 +
116 + session.add(lesson)
117 + # Commit per-row so partial progress survives a crash mid-batch.
118 + await session.commit()
119 +
120 + logger.info(
121 + f"Palace lesson drainer complete: ingested={ingested}, failed={failed}",
122 + )
123 +
124 + await _mark_job_success(session)
125 +
126 +
127 +async def _mark_job_success(session) -> None:
128 + """Update JobMetadata.last_success for the drainer job."""
129 + stmt = select(JobMetadata).where(JobMetadata.job_id == JOB_ID)
130 + result = await session.execute(stmt)
131 + job_metadata = result.scalars().first()
132 + if job_metadata:
133 + job_metadata.last_success = datetime.utcnow()
134 + session.add(job_metadata)
135 + await session.commit()
136 + else:
137 + logger.warning(f"JobMetadata for {JOB_ID!r} not found")
backend/app/schedulers/services/invoke_palace_lesson_sweeper.py new
+146
@@ -0,0 +1,146 @@
1 +"""
2 +Palace lesson durability sweeper — Step 21.A of the CoPilot ↔ NanoClaw
3 +Talon integration.
4 +
5 +Scans the ``ai_analyst_palace_lesson`` table for rows that meet all of:
6 + - durability == 'one_off'
7 + - status == 'ingested'
8 + - drawer_id is not null
9 + - ingested_at is older than ONE_OFF_EXPIRY_DAYS
10 +
11 +For each match, POSTs {"drawer_id": ...} to NanoClaw's ``/palace/forget``
12 +endpoint (which wraps ``mempalace.tool_delete_drawer``). On any response
13 +— success or failure — the row is flipped to status='expired' with
14 +``expired_at = now()`` so it never gets re-processed. A failed forget is
15 +logged as a warning but not retried: the lesson is past its shelf life
16 +either way, and a stuck row would jam the sweeper forever.
17 +
18 +Scheduling: hourly batch drain, capped at DEFAULT_BATCH_SIZE per tick to
19 +keep the scheduler responsive if a big expiry wave lands at once.
20 +"""
21 +from datetime import datetime
22 +from datetime import timedelta
23 +
24 +from loguru import logger
25 +from sqlalchemy.future import select
26 +
27 +from app.connectors.talon.utils.universal import send_post_request
28 +from app.db.db_session import get_db_session
29 +from app.db.universal_models import AiAnalystPalaceLesson
30 +from app.schedulers.models.scheduler import JobMetadata
31 +
32 +JOB_ID = "invoke_palace_lesson_sweeper"
33 +
34 +# One-off lessons expire this many days after their ingested_at timestamp.
35 +# Durable lessons are never swept — they live in MemPalace indefinitely
36 +# until a human removes them manually.
37 +ONE_OFF_EXPIRY_DAYS = 7
38 +
39 +# Max rows forgotten per tick. A large backlog drains over multiple ticks
40 +# instead of blocking the event loop or overwhelming NanoClaw.
41 +DEFAULT_BATCH_SIZE = 25
42 +
43 +
44 +async def invoke_palace_lesson_sweeper() -> None:
45 + """
46 + Forget one batch of expired one-off palace lessons.
47 +
48 + Silently returns (logging only) when:
49 + - No rows have aged past the expiry window.
50 + - The Talon connector is not configured in the DB.
51 + - ``/palace/forget`` returns success=False (row → 'expired' anyway).
52 +
53 + Exceptions never propagate — APScheduler will re-invoke on the next
54 + tick regardless, and the EVENT_JOB_ERROR listener noise is not useful
55 + here when the fix is "wait for the next run."
56 + """
57 + logger.info("Palace lesson sweeper tick")
58 +
59 + cutoff = datetime.utcnow() - timedelta(days=ONE_OFF_EXPIRY_DAYS)
60 +
61 + async with get_db_session() as session:
62 + stmt = (
63 + select(AiAnalystPalaceLesson)
64 + .where(AiAnalystPalaceLesson.durability == "one_off")
65 + .where(AiAnalystPalaceLesson.status == "ingested")
66 + .where(AiAnalystPalaceLesson.drawer_id.is_not(None))
67 + .where(AiAnalystPalaceLesson.ingested_at.is_not(None))
68 + .where(AiAnalystPalaceLesson.ingested_at < cutoff)
69 + .order_by(AiAnalystPalaceLesson.ingested_at.asc())
70 + .limit(DEFAULT_BATCH_SIZE)
71 + )
72 + result = await session.execute(stmt)
73 + lessons = result.scalars().all()
74 +
75 + if not lessons:
76 + logger.debug("No expired one-off palace lessons to sweep")
77 + await _mark_job_success(session)
78 + return
79 +
80 + logger.info(f"Sweeping {len(lessons)} expired one-off lessons (cutoff={cutoff.isoformat()})")
81 +
82 + forgotten = 0
83 + forget_failed = 0
84 +
85 + for lesson in lessons:
86 + payload = {"drawer_id": lesson.drawer_id}
87 +
88 + try:
89 + response = await send_post_request(
90 + endpoint="/palace/forget",
91 + data=payload,
92 + timeout=30,
93 + )
94 + except Exception as e:
95 + # Defensive — send_post_request already traps its own
96 + # exceptions, but we never want one bad row to block the
97 + # batch. Record the row as expired regardless.
98 + logger.error(
99 + f"Unexpected error forgetting palace lesson {lesson.id}: {e}",
100 + )
101 + response = {"success": False, "message": str(e)}
102 +
103 + # Mempalace returns {success, drawer_id, error?} under the
104 + # top-level "data" key when send_post_request succeeds.
105 + body = response.get("data") if isinstance(response.get("data"), dict) else {}
106 + mem_success = bool(body.get("success"))
107 +
108 + if response.get("success") and mem_success:
109 + forgotten += 1
110 + logger.info(f"Lesson {lesson.id} forgotten (drawer_id={lesson.drawer_id}, customer={lesson.customer_code})")
111 + else:
112 + forget_failed += 1
113 + logger.warning(
114 + f"Palace lesson {lesson.id} forget failed "
115 + f"(drawer_id={lesson.drawer_id}); "
116 + f"flipping to expired anyway. "
117 + f"transport_error={response.get('message')}, "
118 + f"mem_error={body.get('error')}",
119 + )
120 +
121 + # Flip to expired either way — the lesson is past shelf life,
122 + # and a stuck row would clog the sweeper on every future tick.
123 + lesson.status = "expired"
124 + lesson.expired_at = datetime.utcnow()
125 + session.add(lesson)
126 + # Commit per-row so partial progress survives a mid-batch crash.
127 + await session.commit()
128 +
129 + logger.info(
130 + f"Palace lesson sweeper complete: forgotten={forgotten}, " f"forget_failed={forget_failed}",
131 + )
132 +
133 + await _mark_job_success(session)
134 +
135 +
136 +async def _mark_job_success(session) -> None:
137 + """Update JobMetadata.last_success for the sweeper job."""
138 + stmt = select(JobMetadata).where(JobMetadata.job_id == JOB_ID)
139 + result = await session.execute(stmt)
140 + job_metadata = result.scalars().first()
141 + if job_metadata:
142 + job_metadata.last_success = datetime.utcnow()
143 + session.add(job_metadata)
144 + await session.commit()
145 + else:
146 + logger.warning(f"JobMetadata for {JOB_ID!r} not found")
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.55"
10 +CURRENT_VERSION = "0.1.56"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
docs/docs.json
+1
@@ -199,6 +199,7 @@
199 "power-features/web-vulnerability-assessment",
200 "power-features/github-audit",
201 "power-features/ai-analyst",
202 + "power-features/ai-analyst-review",
203 "power-features/atomic-red-team",
204 "power-features/report-creation",
205 "power-features/copilot-searches",
docs/power-features/ai-analyst-review.mdx new
+248
@@ -0,0 +1,248 @@
1 +---
2 +title: AI Analyst — Analyst review workflow
3 +description: Review and grade every AI investigation, correct IOC verdicts, teach the agent with durable or one-off lessons, replay with a different template, and track feedback trends over time.
4 +---
5 +
6 +Every report Talon produces is a **draft**. The review workflow lets a SOC analyst grade it, correct what's wrong, teach the agent with a lesson, and — if needed — replay the investigation with a different template. Feedback is aggregated per customer so you can see which templates are reliable and which need tuning.
7 +
8 +This page is for **operators** (analysts reviewing reports). For the architecture and deployment guide, see [AI Analyst (Talon)](/power-features/ai-analyst).
9 +
10 +---
11 +
12 +## Why review matters
13 +
14 +AI reports are fast, consistent, and cheap — but they're not infallible. Without a feedback loop you can't tell:
15 +
16 +- Whether the agent picked the right investigation template
17 +- Whether IOC verdicts match reality (was that hash really malicious?)
18 +- Whether the severity call was appropriate for your environment
19 +- What recurring patterns the agent should treat as benign (and stop paging you about)
20 +
21 +The review workflow turns every investigation into a training signal. Lessons you capture land in **MemPalace** — the agent's persistent memory — and are surfaced on the next investigation for that customer.
22 +
23 +---
24 +
25 +## Where it lives in the UI
26 +
27 +Reviews and feedback live across two places:
28 +
29 +| Location | Purpose |
30 +|----------|---------|
31 +| **Incident Management → Alert → AI Analyst tab → Review** | Grade a specific report, correct IOCs, queue a lesson, replay |
32 +| **AI Analyst page → Reports** | Browse all reports, jump into any to review |
33 +| **AI Analyst page → Feedback** | Per-customer rollup: thumbs, ratings, template accuracy, IOC accuracy, recent reviews |
34 +
35 +---
36 +
37 +## Reviewing a report
38 +
39 +### Open the report
40 +
41 +1. **Incident Management → Alerts** → open any alert that has an AI investigation
42 +2. Click the **AI Analyst** tab (pulses if a report exists)
43 +3. Inside that tab, click **Review**
44 +
45 +You'll see a rubric if this is the first review, or your previous grades pre-filled if you've reviewed this report before — submitting again updates the existing review (one review per analyst per report).
46 +
47 +### The rubric
48 +
49 +| Field | What it captures |
50 +|-------|------------------|
51 +| **Overall verdict** | Thumbs up / down — fast signal, shows up in the dashboard |
52 +| **Template choice** | `correct` / `partial` / `wrong` — was the right investigation template picked? |
53 +| **Rating: instructions** | 1–5 — did the agent follow the template's instructions? |
54 +| **Rating: artifacts** | 1–5 — did it find and cite the right evidence from SIEM? |
55 +| **Rating: severity** | 1–5 — did the severity assessment match reality? |
56 +| **Missing steps** | Free-text — what should the agent have done but didn't? |
57 +| **Suggested edits** | Free-text — specific rewrites or additions for the report |
58 +
59 +Leave any axis blank if you don't have a confident opinion — averages ignore nulls.
60 +
61 +### IOC corrections
62 +
63 +Below the rubric you'll see the IOCs the agent extracted, each with its VirusTotal verdict. For every IOC you can mark:
64 +
65 +- **Verdict correct** ✓ — agent's verdict matches reality
66 +- **Verdict wrong** ✗ — explain in the note field (e.g. "this IP is our jumphost, not malicious")
67 +
68 +IOC-level accuracy rolls up into the feedback dashboard separately from the overall rubric — useful for spotting when the agent trusts VirusTotal too much or too little for your environment.
69 +
70 +### Submit
71 +
72 +Click **Submit review** (or **Update review** if you're editing). The review persists immediately — no pending state.
73 +
74 +---
75 +
76 +## Teach the palace
77 +
78 +The **Teach the palace** section under the rubric lets you add a lesson to the agent's persistent memory. Lessons get retrieved automatically at the start of every investigation for that customer, so the next time the agent sees a similar pattern it already has your context.
79 +
80 +### When to add a lesson
81 +
82 +- **After a false positive** — "Host X runs nightly backups at 02:00 UTC; Sysmon 1 on robocopy during that window is benign"
83 +- **After confirming threat intel** — "APT group Y targets customer; any outbound to IP range Z should be escalated"
84 +- **Asset context** — "DC-01 is the primary domain controller; any unsigned binary execution there is critical"
85 +- **Environment specifics** — "This customer uses piHole at 192.168.1.53; DNS traffic to that IP is expected"
86 +
87 +### Lesson types (rooms)
88 +
89 +Lessons are filed into one of four "rooms" so the agent can retrieve them by context:
90 +
91 +| Room | Use for |
92 +|------|---------|
93 +| `environment` | Customer infra, network layout, scheduled jobs, expected traffic patterns |
94 +| `false_positives` | Confirmed benign patterns that should stop paging the on-call |
95 +| `assets` | Per-host context — role, owner, criticality, known-good processes |
96 +| `threat_intel` | Campaigns, IOC blocklists, TTP notes specific to this customer |
97 +
98 +Pick the room that matches how you'd want to retrieve the lesson later.
99 +
100 +### Durable vs one-off
101 +
102 +| Durability | TTL | Use for |
103 +|------------|-----|---------|
104 +| **Durable** | Never expires | Long-term truths — "DC-01 is the PDC", "customer uses Cloudflare" |
105 +| **One-off** | 7 days | Temporary context — "maintenance window April 15–17", "incident IR-2025-0042 in progress" |
106 +
107 +One-off lessons are swept automatically after their TTL — CoPilot tracks the expiry and tells MemPalace to forget them. Keep the palace clean so retrieval stays relevant.
108 +
109 +### Similar-lessons preview
110 +
111 +As you type a lesson, a debounced search runs against the palace and shows up to 5 already-stored lessons that overlap your draft. Use it to:
112 +
113 +- Avoid duplicating an existing lesson
114 +- See what the agent already "knows" about this pattern
115 +- Phrase the new lesson consistently with prior ones
116 +
117 +### Submit the lesson
118 +
119 +Click **Queue lesson**. The lesson is persisted to CoPilot's database with `status=pending`. A background drainer (APScheduler) picks it up within ~30 seconds, POSTs to Talon, and flips the row to `status=ingested` with a `drawer_id` handle. After that, the agent will retrieve it on the next investigation for that customer.
120 +
121 +---
122 +
123 +## Replay with a different template
124 +
125 +If the agent picked the wrong template — or you want to try a different one — click **Replay** on the Review tab.
126 +
127 +1. The modal lists all templates currently deployed in Talon's `groups/copilot/prompts/` directory
128 +2. Pick a template (e.g. `sysmon_event_1.txt`, `windows_defender.txt`)
129 +3. Click **Replay**
130 +
131 +Talon spins up a **brand-new investigation job** for the same alert with your chosen template forced. The original report is untouched — CoPilot now has two (or more) reports for the alert, and the **Compare** tab lets you view them side-by-side.
132 +
133 +Good use cases:
134 +
135 +- Agent ran the generic template when a specific one would've been better
136 +- You want to see how a different template frames the same raw evidence
137 +- A/B test a newly tuned template against the previous one
138 +
139 +---
140 +
141 +## Palace consolidation
142 +
143 +Over time the palace accumulates lessons. Some expire, some duplicate each other, some get stale. The **Consolidate lessons** button (Feedback tab → top right) opens a point-in-time digest for the selected customer:
144 +
145 +| Panel | What it shows |
146 +|-------|---------------|
147 +| **Summary tiles** | Total active, durable, one-off, near-duplicate pair count |
148 +| **Expiring soon** | One-off lessons within 2 days of expiry — act now or let them lapse |
149 +| **Near-duplicate candidates** | Lesson pairs above 70% similarity — merge or delete |
150 +| **By room** | Full lesson list grouped by room, with durability + status badges |
151 +
152 +Click **Copy markdown** to paste the digest into a ticket, a team channel, or your own knowledge base — useful for monthly palace reviews.
153 +
154 +This is read-only — you can't edit lessons from the drawer. To remove a lesson, either wait for the one-off TTL or mark the row manually via the database / API.
155 +
156 +---
157 +
158 +## Feedback dashboard
159 +
160 +**AI Analyst page → Feedback tab.** Pick a customer and see:
161 +
162 +### Tiles
163 +
164 +- **Total reviews** — how much feedback you have for this customer
165 +- **Thumbs up %** — overall sentiment
166 +- **IOC verdict accuracy %** — of all IOC corrections submitted, how often did the agent agree with the analyst
167 +- **Avg rating (overall)** — composite of instructions / artifacts / severity (nulls excluded)
168 +
169 +### Template choice distribution
170 +
171 +Stacked bar — `correct` / `partial` / `wrong`. If the "wrong" bar is non-trivial, your agent is mis-selecting templates. Candidates for fixing:
172 +
173 +- The template detection logic (`rule.groups` matching in Talon)
174 +- Adding a more specific template for the miss case
175 +
176 +### Per-template performance
177 +
178 +Table showing per-template counts and averages. Use it to spot:
179 +
180 +- Templates with consistently low `instructions` ratings → the template itself may be wrong
181 +- Templates with high `template_choice=wrong` for a rule type → detection rule is mis-classified
182 +- Templates with low IOC accuracy → the template's enrichment steps may be flawed
183 +
184 +### Recent reviews
185 +
186 +Last 10 reviews with drill-in. Click any one to open a drawer with the full rubric, IOC corrections, and free-text fields.
187 +
188 +---
189 +
190 +## Typical workflows
191 +
192 +### Fast triage (10 seconds)
193 +
194 +1. Open alert → AI Analyst tab → skim report
195 +2. If report matches reality → thumbs up, submit
196 +3. If obviously wrong → thumbs down, one-line in "Missing steps", submit
197 +
198 +### False-positive capture (30 seconds)
199 +
200 +1. Confirm the alert is benign (e.g. scheduled job, known-good process)
201 +2. Review tab → thumbs down → template choice `correct` (template was right, signal was noise)
202 +3. **Teach the palace** → room `false_positives`, durable, describe the benign pattern with enough detail that the agent would recognize it next time
203 +4. Queue lesson → submit review
204 +
205 +### Template tuning (2 minutes)
206 +
207 +1. Report picked a bad template → review it, template choice `wrong`
208 +2. Note in "Suggested edits" what template *should* have been used
209 +3. Click **Replay** → select the correct template → submit
210 +4. Compare tab → confirm the new report is better
211 +5. Report the mis-selection pattern to whoever maintains Talon's template detection
212 +
213 +### Monthly palace review (10 minutes)
214 +
215 +1. Feedback tab → pick customer → **Consolidate lessons**
216 +2. Review **Expiring soon** — promote anything still valid from one-off to a fresh durable lesson
217 +3. Review **Near-duplicates** — pick the better-worded lesson, manually delete the other
218 +4. **Copy markdown** → paste into your team's wiki for the customer
219 +
220 +---
221 +
222 +## Safety & guardrails
223 +
224 +- **Reviews are analyst-scoped** — one review per analyst per report, updates overwrite. Multiple analysts can each leave their own review.
225 +- **Lessons are customer-scoped** — a lesson queued for customer `00001` is only retrieved on investigations for that customer.
226 +- **One-off lessons auto-expire** — use them for temporary context so the palace stays clean.
227 +- **Replays don't mutate the original** — every replay is a new job/report; the original stays for comparison.
228 +- **Palace consolidation is read-only** — you can't accidentally delete the palace from the UI.
229 +- **Don't put secrets in lessons** — lesson text is sent to Talon and embedded by MemPalace / ChromaDB. Treat it as you would a SIEM comment.
230 +
231 +---
232 +
233 +## Troubleshooting
234 +
235 +| Symptom | Likely cause | Fix |
236 +|---------|--------------|-----|
237 +| "Review" tab missing | Report doesn't exist yet | Click **Investigate with AI Analyst** on the alert Overview tab first |
238 +| Lesson stays `pending` forever | Drainer job not running | Check CoPilot scheduler logs for `invoke_palace_lesson_drainer` |
239 +| Lesson ingested but not retrieved on next investigation | Customer code mismatch, or wrong room | Verify lesson's `customer_code` matches the alert's; check palace search with the expected query |
240 +| Replay modal shows no templates | Talon unreachable | Check `GET /api/talon/templates` — should list `.txt` files from `groups/copilot/prompts/` |
241 +| Feedback dashboard shows zero reviews | No reviews submitted yet, or wrong customer picked | Submit at least one review, confirm the customer dropdown matches the alert's code |
242 +| IOC accuracy shows `0/0` | No IOC corrections submitted | Review individual IOCs on the Review tab, not just the overall rubric |
243 +
244 +---
245 +
246 +## Video context
247 +
248 +- AI analyst (alert-context + exclusion-rule assistance): https://www.youtube.com/watch?v=-2srPC-Dw-0
frontend/src/api/endpoints/aiAnalyst.ts
+101 -1
@@ -1,4 +1,17 @@
1 -import type { AiAnalystIoc, AiAnalystJob, AiAnalystReport, AlertWithReport } from "@/types/aiAnalyst.d"
1 +import type {
2 + AiAnalystIoc,
3 + AiAnalystJob,
4 + AiAnalystPalaceLesson,
5 + AiAnalystReport,
6 + AiAnalystReview,
7 + AiAnalystReviewStats,
8 + AlertWithReport,
9 + PalaceConsolidation,
10 + PalaceSearchHit,
11 + QueuePalaceLessonPayload,
12 + ReplayPayload,
13 + SubmitReviewPayload
14 +} from "@/types/aiAnalyst.d"
15 import type { FlaskBaseResponse } from "@/types/flask.d"
16 import { HttpClient } from "../httpClient"
17
@@ -102,5 +115,92 @@ export default {
115 iocs: AiAnalystIoc[] | null
116 }
117 >(`/ai_analyst/alert/${alertId}`)
118 + },
119 +
120 + // --- Reviews ---
121 + /**
122 + * Fetch the current user's existing review for a report (if any).
123 + * Returns review=null in create-mode so the UI shows a fresh rubric.
124 + */
125 + getMyReview(reportId: number) {
126 + return HttpClient.get<FlaskBaseResponse & { review: AiAnalystReview | null }>(
127 + `/ai_analyst/reports/${reportId}/review/mine`
128 + )
129 + },
130 + /**
131 + * Upsert the current user's review for a report. Backend enforces
132 + * one review per (report, user) via unique constraint — a second call
133 + * updates the existing row and sets updated_at.
134 + */
135 + submitReview(reportId: number, payload: SubmitReviewPayload) {
136 + return HttpClient.post<FlaskBaseResponse & { review: AiAnalystReview }>(
137 + `/ai_analyst/reports/${reportId}/review`,
138 + payload
139 + )
140 + },
141 + getReviewsByCustomer(customerCode: string) {
142 + return HttpClient.get<FlaskBaseResponse & { reviews: AiAnalystReview[] }>(
143 + `/ai_analyst/reviews/customer/${customerCode}`
144 + )
145 + },
146 + /**
147 + * SQL-side feedback dashboard rollup — counts, averages, template
148 + * breakdown, IOC accuracy, and embedded recent reviews for drill-in.
149 + */
150 + getReviewStats(customerCode: string, recentLimit = 10) {
151 + return HttpClient.get<FlaskBaseResponse & AiAnalystReviewStats>(
152 + `/ai_analyst/reviews/customer/${customerCode}/stats`,
153 + {
154 + params: { recent_limit: recentLimit }
155 + }
156 + )
157 + },
158 +
159 + // --- Replay ---
160 + /**
161 + * Re-run an investigation for the report's alert with a forced template.
162 + * The replay creates its own new job/report via Talon's normal callbacks —
163 + * this call does not mutate local DB itself.
164 + */
165 + replayReport(reportId: number, payload: ReplayPayload) {
166 + return HttpClient.post<FlaskBaseResponse & { data?: Record<string, unknown> }>(
167 + `/ai_analyst/reports/${reportId}/replay`,
168 + payload
169 + )
170 + },
171 +
172 + // --- Palace lessons ---
173 + queuePalaceLesson(payload: QueuePalaceLessonPayload) {
174 + return HttpClient.post<FlaskBaseResponse & { lesson: AiAnalystPalaceLesson }>(
175 + `/ai_analyst/palace_lessons`,
176 + payload
177 + )
178 + },
179 + /**
180 + * Preview similar lessons already stored in MemPalace — debounced against
181 + * the lesson-text textarea so the reviewer can see overlap before queueing.
182 + */
183 + searchPalaceLessons(customerCode: string, query: string, room?: string, limit = 5) {
184 + return HttpClient.get<FlaskBaseResponse & { lessons: PalaceSearchHit[] }>(
185 + `/ai_analyst/palace_lessons/customer/${customerCode}`,
186 + {
187 + params: {
188 + query,
189 + limit,
190 + ...(room ? { room } : {})
191 + }
192 + }
193 + )
194 + },
195 + /**
196 + * Manual consolidation digest — builds a point-in-time view of a
197 + * customer's active MemPalace lessons (pending + ingested), grouped
198 + * by room, with near-duplicate pairs and upcoming expirations
199 + * surfaced for reviewer action. Pure read-only, no Talon round-trip.
200 + */
201 + getPalaceConsolidation(customerCode: string) {
202 + return HttpClient.get<FlaskBaseResponse & PalaceConsolidation>(
203 + `/ai_analyst/palace_lessons/customer/${customerCode}/consolidation`
204 + )
205 }
206 }
frontend/src/api/endpoints/talon.ts
+8 -1
@@ -1,5 +1,5 @@
1 import type { FlaskBaseResponse } from "@/types/flask.d"
2 -import type { TalonInvestigateRequest, TalonJobData } from "@/types/talon.d"
2 +import type { TalonInvestigateRequest, TalonJobData, TalonTemplate } from "@/types/talon.d"
3 import { useAuthStore } from "@/stores/auth"
4 import { HttpClient } from "../httpClient"
5
@@ -13,6 +13,13 @@ export default {
13 getJob(alertId: number) {
14 return HttpClient.get<FlaskBaseResponse & { data?: TalonJobData }>(`/talon/jobs/${alertId}`)
15 },
16 + /**
17 + * List the prompt templates available in NanoClaw's CoPilot group.
18 + * Used by the replay picker in the review UI — metadata only, no bodies.
19 + */
20 + getTemplates() {
21 + return HttpClient.get<FlaskBaseResponse & { templates: TalonTemplate[] }>(`/talon/templates`)
22 + },
23 /**
24 * Stream a message to Talon via SSE.
25 *
frontend/src/components/aiAnalyst/AlertReportCompare.vue new
+137
@@ -0,0 +1,137 @@
1 +<template>
2 + <n-spin :show="loading" class="min-h-40">
3 + <div class="flex flex-col gap-4">
4 + <div v-if="!loading && reports.length < 2">
5 + <n-empty
6 + description="Only one report exists for this alert. Replay with a different template to generate a second report to compare."
7 + class="min-h-40 justify-center"
8 + />
9 + </div>
10 +
11 + <template v-else>
12 + <div class="text-secondary text-sm">
13 + Side-by-side view of two investigations for alert
14 + <code class="text-primary">#{{ alertId }}</code>. Pick any two runs below — defaults
15 + to the current report on the left and the next-most-recent run on the right.
16 + </div>
17 +
18 + <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
19 + <!-- Side A -->
20 + <div class="flex flex-col gap-3">
21 + <div>
22 + <div class="mb-1 font-medium">Version A</div>
23 + <n-select
24 + v-model:value="idA"
25 + :options="reportOptions"
26 + :render-label="renderOption"
27 + />
28 + </div>
29 + <ReportColumn v-if="reportA" :report="reportA" />
30 + </div>
31 +
32 + <!-- Side B -->
33 + <div class="flex flex-col gap-3">
34 + <div>
35 + <div class="mb-1 font-medium">Version B</div>
36 + <n-select
37 + v-model:value="idB"
38 + :options="reportOptions"
39 + :render-label="renderOption"
40 + />
41 + </div>
42 + <ReportColumn v-if="reportB" :report="reportB" />
43 + </div>
44 + </div>
45 + </template>
46 + </div>
47 + </n-spin>
48 +</template>
49 +
50 +<script setup lang="ts">
51 +import type { AiAnalystReport } from "@/types/aiAnalyst.d"
52 +import { h } from "vue"
53 +import { NEmpty, NSelect, NSpin, useMessage } from "naive-ui"
54 +import { computed, onBeforeMount, ref, toRefs, watch } from "vue"
55 +import Api from "@/api"
56 +import { formatDate } from "@/utils/format"
57 +import ReportColumn from "./AlertReportCompareColumn.vue"
58 +
59 +const props = defineProps<{
60 + alertId: number
61 + currentReportId?: number
62 +}>()
63 +
64 +const { alertId, currentReportId } = toRefs(props)
65 +
66 +const message = useMessage()
67 +const loading = ref(false)
68 +const reports = ref<AiAnalystReport[]>([])
69 +
70 +const idA = ref<number | null>(null)
71 +const idB = ref<number | null>(null)
72 +
73 +const reportOptions = computed(() =>
74 + reports.value.map(r => ({
75 + label: r.id.toString(),
76 + value: r.id,
77 + severity: r.severity_assessment,
78 + created_at: r.created_at
79 + }))
80 +)
81 +
82 +// Render option with created_at + severity so the picker shows meaningful
83 +// distinctions between runs rather than just bare IDs.
84 +function renderOption(option: {
85 + label: string
86 + value: number
87 + severity?: string | null
88 + created_at?: string
89 +}) {
90 + const ts = option.created_at ? String(formatDate(option.created_at, "MMM D, YYYY HH:mm")) : ""
91 + const sev = option.severity ? ` · ${option.severity}` : ""
92 + return h("div", { class: "flex flex-col" }, [
93 + h("span", `#${option.label}${sev}`),
94 + h("span", { class: "text-secondary text-xs" }, ts)
95 + ])
96 +}
97 +
98 +const reportA = computed(() => reports.value.find(r => r.id === idA.value) ?? null)
99 +const reportB = computed(() => reports.value.find(r => r.id === idB.value) ?? null)
100 +
101 +async function loadReports() {
102 + loading.value = true
103 + try {
104 + const res = await Api.aiAnalyst.getReportsByAlert(alertId.value)
105 + if (res.data.success) {
106 + // Newest first — backend already sorts by created_at desc, but we
107 + // re-sort defensively in case that changes.
108 + const sorted = [...(res.data.reports || [])].sort(
109 + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
110 + )
111 + reports.value = sorted
112 + if (sorted.length >= 2) {
113 + // Default A to the currentReportId if provided (so the user always
114 + // sees "this report" on the left), otherwise newest.
115 + const preferA = currentReportId?.value
116 + ? sorted.find(r => r.id === currentReportId.value)?.id
117 + : sorted[0].id
118 + idA.value = preferA ?? sorted[0].id
119 + idB.value = sorted.find(r => r.id !== idA.value)?.id ?? sorted[1].id
120 + }
121 + } else {
122 + message.warning(res.data.message || "Failed to load reports")
123 + }
124 + } catch (err: unknown) {
125 + const e = err as { response?: { data?: { message?: string } }; message?: string }
126 + message.error(e.response?.data?.message || e.message || "Failed to load reports")
127 + } finally {
128 + loading.value = false
129 + }
130 +}
131 +
132 +watch(alertId, () => loadReports())
133 +
134 +onBeforeMount(() => {
135 + loadReports()
136 +})
137 +</script>
frontend/src/components/aiAnalyst/AlertReportCompareColumn.vue new
+64
@@ -0,0 +1,64 @@
1 +<template>
2 + <div class="flex flex-col gap-3">
3 + <div class="flex flex-wrap items-center gap-2">
4 + <Badge v-if="report.severity_assessment" type="splitted" bright :color="severityColor">
5 + <template #label>Severity</template>
6 + <template #value>{{ report.severity_assessment }}</template>
7 + </Badge>
8 + <Badge type="splitted">
9 + <template #label>Report</template>
10 + <template #value>#{{ report.id }}</template>
11 + </Badge>
12 + <Badge type="splitted">
13 + <template #label>Created</template>
14 + <template #value>{{ formatDate(report.created_at, "MMM D, YYYY HH:mm") }}</template>
15 + </Badge>
16 + </div>
17 +
18 + <CardKV v-if="report.summary">
19 + <template #key>Summary</template>
20 + <template #value>{{ report.summary }}</template>
21 + </CardKV>
22 +
23 + <CardKV v-if="report.recommended_actions">
24 + <template #key>Recommended actions</template>
25 + <template #value>{{ report.recommended_actions }}</template>
26 + </CardKV>
27 +
28 + <n-collapse>
29 + <n-collapse-item name="markdown">
30 + <template #header>
31 + <span class="font-medium">Full report</span>
32 + </template>
33 + <div v-if="report.report_markdown" class="pt-2">
34 + <Markdown :source="report.report_markdown" breaks />
35 + </div>
36 + <n-empty v-else description="No report content" class="min-h-20 justify-center" />
37 + </n-collapse-item>
38 + </n-collapse>
39 + </div>
40 +</template>
41 +
42 +<script setup lang="ts">
43 +import type { AiAnalystReport } from "@/types/aiAnalyst.d"
44 +import { NCollapse, NCollapseItem, NEmpty } from "naive-ui"
45 +import { computed, toRefs } from "vue"
46 +import Badge from "@/components/common/Badge.vue"
47 +import CardKV from "@/components/common/cards/CardKV.vue"
48 +import Markdown from "@/components/common/Markdown.vue"
49 +import { formatDate } from "@/utils/format"
50 +
51 +const props = defineProps<{
52 + report: AiAnalystReport
53 +}>()
54 +
55 +const { report } = toRefs(props)
56 +
57 +const severityColor = computed(() => {
58 + const severity = report.value.severity_assessment
59 + if (severity === "Critical" || severity === "High") return "danger"
60 + if (severity === "Medium") return "warning"
61 + if (severity === "Low" || severity === "Informational") return "success"
62 + return undefined
63 +})
64 +</script>
frontend/src/components/aiAnalyst/AlertReportDetails.vue
+12
@@ -40,6 +40,16 @@
40 <AlertReportJobsList :alert-id="alert.alert_id" />
41 </div>
42 </n-tab-pane>
43 + <n-tab-pane name="Review" tab="Review" display-directive="show:lazy">
44 + <div class="p-6 pt-3">
45 + <AlertReportReviewPanel :report="report" />
46 + </div>
47 + </n-tab-pane>
48 + <n-tab-pane name="Compare" tab="Compare" display-directive="show:lazy">
49 + <div class="p-6 pt-3">
50 + <AlertReportCompare :alert-id="alert.alert_id" :current-report-id="report.id" />
51 + </div>
52 + </n-tab-pane>
53 </n-tabs>
54 </n-spin>
55 </template>
@@ -60,6 +70,8 @@ const { alert } = toRefs(props)
70
71 const AlertReportIocsList = defineAsyncComponent(() => import("./AlertReportIocsList.vue"))
72 const AlertReportJobsList = defineAsyncComponent(() => import("./AlertReportJobsList.vue"))
73 +const AlertReportReviewPanel = defineAsyncComponent(() => import("./AlertReportReviewPanel.vue"))
74 +const AlertReportCompare = defineAsyncComponent(() => import("./AlertReportCompare.vue"))
75
76 const loading = ref(false)
77 const report = computed(() => alert.value.report)
frontend/src/components/aiAnalyst/AlertReportReviewPanel.vue new
+555
@@ -0,0 +1,555 @@
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>
frontend/src/components/aiAnalyst/AlertsReportsList.vue
+10 -2
@@ -18,7 +18,6 @@
18 :show-checkmark="false"
19 class="min-w-40"
20 :disabled="loading"
21 - @update:value="getData()"
21 />
22 <n-select
23 v-model:value="sort"
@@ -56,7 +55,7 @@
55 <script setup lang="ts">
56 import type { AlertWithReport } from "@/types/aiAnalyst.d"
57 import { NEmpty, NSelect, NSpin, useMessage } from "naive-ui"
59 -import { computed, onBeforeMount, ref } from "vue"
58 +import { computed, onBeforeMount, ref, watch } from "vue"
59 import Api from "@/api"
60 import { getApiErrorMessage } from "@/utils"
61 import AlertReportItem from "./AlertReportItem.vue"
@@ -109,6 +108,15 @@ function getData() {
108 })
109 }
110
111 +// Refetch only on actual user-driven filter changes. Previously wired via
112 +// @update:value on the select, but naive-ui can fire update:value when the
113 +// options prop identity changes (our computed returns a new array each time
114 +// alertsList updates) — that created a feedback loop: fetch → options ref
115 +// changes → update:value fires → fetch again.
116 +watch(customerFilter, () => {
117 + getData()
118 +})
119 +
120 onBeforeMount(() => {
121 getData()
122 })
frontend/src/components/aiAnalyst/FeedbackDashboard.vue new
+441
@@ -0,0 +1,441 @@
1 +<template>
2 + <div class="feedback-dashboard @container flex flex-col gap-5">
3 + <!-- Customer picker -->
4 + <div class="flex flex-wrap items-center justify-between gap-3">
5 + <div class="flex items-center gap-2 text-sm">
6 + <span>Customer</span>
7 + <n-select
8 + v-model:value="customer"
9 + size="small"
10 + placeholder="Select a customer"
11 + :options="customerOptions"
12 + :show-checkmark="false"
13 + class="min-w-52"
14 + :disabled="loading || customerBootstrapLoading"
15 + />
16 + </div>
17 + <div class="flex items-center gap-2">
18 + <n-button size="small" :disabled="!customer" @click="showConsolidation = true">
19 + <template #icon>
20 + <Icon :name="ConsolidateIcon" :size="14" />
21 + </template>
22 + Consolidate lessons
23 + </n-button>
24 + <n-button size="small" :disabled="!customer || loading" @click="loadStats()">
25 + <template #icon>
26 + <Icon :name="RefreshIcon" :size="14" />
27 + </template>
28 + Refresh
29 + </n-button>
30 + </div>
31 + </div>
32 +
33 + <n-spin :show="loading" class="min-h-40">
34 + <div v-if="!customer" class="pt-6 text-center">
35 + <n-empty description="Pick a customer to see their review feedback" />
36 + </div>
37 +
38 + <div v-else-if="stats" class="flex flex-col gap-5">
39 + <!-- Metric tiles -->
40 + <div class="grid grid-cols-1 gap-3 md:grid-cols-4">
41 + <MetricTile label="Total reviews" :value="stats.total_reviews.toString()" />
42 + <MetricTile
43 + label="Thumbs up"
44 + :value="pctLabel(stats.thumbs_up_pct)"
45 + :sub="`${stats.thumbs_up} up / ${stats.thumbs_down} down`"
46 + :color="pctColor(stats.thumbs_up_pct)"
47 + />
48 + <MetricTile
49 + label="IOC verdict accuracy"
50 + :value="pctLabel(stats.ioc_accuracy.accuracy_pct)"
51 + :sub="`${stats.ioc_accuracy.correct}/${stats.ioc_accuracy.total} IOCs correct`"
52 + :color="pctColor(stats.ioc_accuracy.accuracy_pct)"
53 + />
54 + <MetricTile
55 + label="Avg rating (overall)"
56 + :value="avgOverall == null ? '—' : `${avgOverall.toFixed(2)} / 5`"
57 + :sub="ratingSubtitle"
58 + />
59 + </div>
60 +
61 + <!-- Template choice breakdown -->
62 + <CardEntity size="small" embedded>
63 + <template #headerMain>Template choice distribution</template>
64 + <template #default>
65 + <div class="flex flex-col gap-2">
66 + <TemplateChoiceBar
67 + label="Correct"
68 + color="success"
69 + :count="stats.template_choice_correct"
70 + :total="templateChoiceTotal"
71 + />
72 + <TemplateChoiceBar
73 + label="Partial"
74 + color="warning"
75 + :count="stats.template_choice_partial"
76 + :total="templateChoiceTotal"
77 + />
78 + <TemplateChoiceBar
79 + label="Wrong"
80 + color="danger"
81 + :count="stats.template_choice_wrong"
82 + :total="templateChoiceTotal"
83 + />
84 + <div v-if="templateChoiceTotal === 0" class="text-secondary text-sm">
85 + No template choice feedback yet.
86 + </div>
87 + </div>
88 + </template>
89 + </CardEntity>
90 +
91 + <!-- Per-template table -->
92 + <CardEntity size="small" embedded>
93 + <template #headerMain>Per-template performance</template>
94 + <template #default>
95 + <n-empty
96 + v-if="!stats.per_template.length"
97 + description="No reviews yet"
98 + class="min-h-20 justify-center"
99 + />
100 + <n-data-table
101 + v-else
102 + :columns="perTemplateColumns"
103 + :data="stats.per_template"
104 + :bordered="false"
105 + size="small"
106 + :row-key="(r: ReviewStatsTemplate) => r.template_used ?? '__null__'"
107 + />
108 + </template>
109 + </CardEntity>
110 +
111 + <!-- Recent reviews -->
112 + <CardEntity size="small" embedded>
113 + <template #headerMain>Recent reviews</template>
114 + <template #default>
115 + <div v-if="!stats.recent_reviews.length" class="text-secondary text-sm">
116 + No reviews yet.
117 + </div>
118 + <div v-else class="flex flex-col gap-2">
119 + <CardEntity
120 + v-for="r of stats.recent_reviews"
121 + :key="r.id"
122 + size="small"
123 + embedded
124 + hoverable
125 + clickable
126 + @click="openDrawer(r)"
127 + >
128 + <template #headerMain>
129 + Report #{{ r.report_id }}
130 + <span v-if="r.template_used" class="text-secondary ml-2 text-sm">
131 + · {{ r.template_used }}
132 + </span>
133 + </template>
134 + <template #headerExtra>
135 + <span class="text-secondary text-sm">
136 + {{ formatDate(r.updated_at ?? r.created_at, "MMM D, YYYY HH:mm") }}
137 + </span>
138 + </template>
139 + <template #default>
140 + <div class="flex flex-wrap items-center gap-3">
141 + <Badge
142 + v-if="r.overall_verdict"
143 + type="splitted"
144 + bright
145 + :color="r.overall_verdict === 'up' ? 'success' : 'danger'"
146 + >
147 + <template #label>Verdict</template>
148 + <template #value>
149 + {{ r.overall_verdict === "up" ? "Up" : "Down" }}
150 + </template>
151 + </Badge>
152 + <Badge
153 + v-if="r.template_choice"
154 + type="splitted"
155 + bright
156 + :color="tplChoiceColor(r.template_choice)"
157 + >
158 + <template #label>Template</template>
159 + <template #value>{{ r.template_choice }}</template>
160 + </Badge>
161 + <Badge v-if="r.rating_instructions" type="splitted">
162 + <template #label>Instr</template>
163 + <template #value>{{ r.rating_instructions }}/5</template>
164 + </Badge>
165 + <Badge v-if="r.rating_artifacts" type="splitted">
166 + <template #label>Artifacts</template>
167 + <template #value>{{ r.rating_artifacts }}/5</template>
168 + </Badge>
169 + <Badge v-if="r.rating_severity" type="splitted">
170 + <template #label>Severity</template>
171 + <template #value>{{ r.rating_severity }}/5</template>
172 + </Badge>
173 + <Badge v-if="r.ioc_reviews.length" type="splitted">
174 + <template #label>IOC corrections</template>
175 + <template #value>{{ r.ioc_reviews.length }}</template>
176 + </Badge>
177 + </div>
178 + </template>
179 + </CardEntity>
180 + </div>
181 + </template>
182 + </CardEntity>
183 + </div>
184 + </n-spin>
185 +
186 + <!-- Drawer: full review detail -->
187 + <n-drawer v-model:show="showDrawer" :width="520" placement="right">
188 + <n-drawer-content v-if="drawerReview" closable>
189 + <template #header>Review for report #{{ drawerReview.report_id }}</template>
190 + <div class="flex flex-col gap-3">
191 + <div class="flex flex-wrap items-center gap-2">
192 + <Badge
193 + v-if="drawerReview.overall_verdict"
194 + type="splitted"
195 + bright
196 + :color="drawerReview.overall_verdict === 'up' ? 'success' : 'danger'"
197 + >
198 + <template #label>Verdict</template>
199 + <template #value>
200 + {{ drawerReview.overall_verdict === "up" ? "Up" : "Down" }}
201 + </template>
202 + </Badge>
203 + <Badge v-if="drawerReview.template_used" type="splitted">
204 + <template #label>Template</template>
205 + <template #value>{{ drawerReview.template_used }}</template>
206 + </Badge>
207 + <Badge v-if="drawerReview.template_choice" type="splitted" bright>
208 + <template #label>Template choice</template>
209 + <template #value>{{ drawerReview.template_choice }}</template>
210 + </Badge>
211 + </div>
212 +
213 + <CardKV v-if="drawerReview.missing_steps">
214 + <template #key>Missing steps</template>
215 + <template #value>{{ drawerReview.missing_steps }}</template>
216 + </CardKV>
217 + <CardKV v-if="drawerReview.suggested_edits">
218 + <template #key>Suggested edits</template>
219 + <template #value>{{ drawerReview.suggested_edits }}</template>
220 + </CardKV>
221 +
222 + <div v-if="drawerReview.ioc_reviews.length" class="flex flex-col gap-2">
223 + <div class="font-medium">IOC corrections</div>
224 + <div
225 + v-for="ir of drawerReview.ioc_reviews"
226 + :key="ir.id"
227 + class="border-color bg-secondary rounded border p-2 text-sm"
228 + >
229 + <div class="flex items-center gap-2">
230 + <Badge type="splitted" :color="ir.verdict_correct ? 'success' : 'danger'">
231 + <template #label>IOC {{ ir.ioc_id }}</template>
232 + <template #value>
233 + {{ ir.verdict_correct ? "Correct" : "Wrong" }}
234 + </template>
235 + </Badge>
236 + </div>
237 + <div v-if="ir.note" class="text-secondary mt-1">{{ ir.note }}</div>
238 + </div>
239 + </div>
240 + </div>
241 + </n-drawer-content>
242 + </n-drawer>
243 +
244 + <!-- Palace consolidation drawer (manual Step 21.B trigger) -->
245 + <PalaceConsolidationDrawer v-model:show="showConsolidation" :customer-code="customer" />
246 + </div>
247 +</template>
248 +
249 +<script setup lang="ts">
250 +import type {
251 + AiAnalystReview,
252 + AiAnalystReviewStats,
253 + ReviewStatsTemplate
254 +} from "@/types/aiAnalyst.d"
255 +import type { DataTableColumns } from "naive-ui"
256 +import {
257 + NButton,
258 + NDataTable,
259 + NDrawer,
260 + NDrawerContent,
261 + NEmpty,
262 + NSelect,
263 + NSpin,
264 + useMessage
265 +} from "naive-ui"
266 +import { computed, onBeforeMount, ref, watch } from "vue"
267 +import Api from "@/api"
268 +import Badge from "@/components/common/Badge.vue"
269 +import CardEntity from "@/components/common/cards/CardEntity.vue"
270 +import CardKV from "@/components/common/cards/CardKV.vue"
271 +import Icon from "@/components/common/Icon.vue"
272 +import { getApiErrorMessage } from "@/utils"
273 +import { formatDate } from "@/utils/format"
274 +import MetricTile from "./FeedbackMetricTile.vue"
275 +import PalaceConsolidationDrawer from "./PalaceConsolidationDrawer.vue"
276 +import TemplateChoiceBar from "./FeedbackTemplateChoiceBar.vue"
277 +
278 +const RefreshIcon = "carbon:renew"
279 +const ConsolidateIcon = "carbon:data-collection"
280 +
281 +const message = useMessage()
282 +
283 +const customer = ref<string | null>(null)
284 +const customerOptions = ref<{ label: string; value: string }[]>([])
285 +const customerBootstrapLoading = ref(false)
286 +
287 +const loading = ref(false)
288 +const stats = ref<AiAnalystReviewStats | null>(null)
289 +
290 +const showDrawer = ref(false)
291 +const drawerReview = ref<AiAnalystReview | null>(null)
292 +
293 +const showConsolidation = ref(false)
294 +
295 +const templateChoiceTotal = computed(() =>
296 + stats.value
297 + ? stats.value.template_choice_correct +
298 + stats.value.template_choice_partial +
299 + stats.value.template_choice_wrong
300 + : 0
301 +)
302 +
303 +// Composite avg across the three rubric axes — only counts axes with data.
304 +const avgOverall = computed(() => {
305 + if (!stats.value) return null
306 + const parts = [
307 + stats.value.avg_rating_instructions,
308 + stats.value.avg_rating_artifacts,
309 + stats.value.avg_rating_severity
310 + ].filter((v): v is number => v !== null)
311 + if (!parts.length) return null
312 + return parts.reduce((a, b) => a + b, 0) / parts.length
313 +})
314 +
315 +const ratingSubtitle = computed(() => {
316 + if (!stats.value) return ""
317 + const i = stats.value.avg_rating_instructions
318 + const a = stats.value.avg_rating_artifacts
319 + const s = stats.value.avg_rating_severity
320 + return `instr ${i ?? "—"} · artif ${a ?? "—"} · sev ${s ?? "—"}`
321 +})
322 +
323 +function pctLabel(pct: number | null): string {
324 + return pct === null || pct === undefined ? "—" : `${pct.toFixed(1)}%`
325 +}
326 +function pctColor(pct: number | null): "success" | "warning" | "danger" | undefined {
327 + if (pct === null) return undefined
328 + if (pct >= 75) return "success"
329 + if (pct >= 50) return "warning"
330 + return "danger"
331 +}
332 +function tplChoiceColor(choice: string): "success" | "warning" | "danger" | undefined {
333 + if (choice === "correct") return "success"
334 + if (choice === "partial") return "warning"
335 + if (choice === "wrong") return "danger"
336 + return undefined
337 +}
338 +
339 +const perTemplateColumns = computed<DataTableColumns<ReviewStatsTemplate>>(() => [
340 + {
341 + title: "Template",
342 + key: "template_used",
343 + render: row => row.template_used ?? "(none)"
344 + },
345 + { title: "Total", key: "total", width: 80 },
346 + {
347 + title: "Up / Down",
348 + key: "verdict",
349 + width: 110,
350 + render: row => `${row.thumbs_up} / ${row.thumbs_down}`
351 + },
352 + {
353 + title: "C / P / W",
354 + key: "choice",
355 + width: 110,
356 + render: row => `${row.correct} / ${row.partial} / ${row.wrong}`
357 + },
358 + {
359 + title: "Instr",
360 + key: "avg_rating_instructions",
361 + width: 80,
362 + render: row => (row.avg_rating_instructions == null ? "—" : row.avg_rating_instructions.toFixed(2))
363 + },
364 + {
365 + title: "Artif",
366 + key: "avg_rating_artifacts",
367 + width: 80,
368 + render: row => (row.avg_rating_artifacts == null ? "—" : row.avg_rating_artifacts.toFixed(2))
369 + },
370 + {
371 + title: "Sev",
372 + key: "avg_rating_severity",
373 + width: 80,
374 + render: row => (row.avg_rating_severity == null ? "—" : row.avg_rating_severity.toFixed(2))
375 + }
376 +])
377 +
378 +function openDrawer(r: AiAnalystReview) {
379 + drawerReview.value = r
380 + showDrawer.value = true
381 +}
382 +
383 +async function bootstrapCustomers() {
384 + // Bootstrap customer picker off alerts_with_reports so we only list
385 + // customers that actually have AI runs — no external customer endpoint call.
386 + customerBootstrapLoading.value = true
387 + try {
388 + const res = await Api.aiAnalyst.getAlertsWithReports()
389 + if (res.data.success) {
390 + const codes = new Set((res.data.alerts || []).map(a => a.customer_code))
391 + customerOptions.value = Array.from(codes)
392 + .sort()
393 + .map(c => ({ label: c, value: c }))
394 + if (customerOptions.value.length && !customer.value) {
395 + // The watch(customer, loadStats) below picks this up — no
396 + // need to call loadStats() explicitly from bootstrap.
397 + customer.value = customerOptions.value[0].value
398 + }
399 + }
400 + } catch (err: unknown) {
401 + message.error(getApiErrorMessage(err as never) || "Failed to load customers")
402 + } finally {
403 + customerBootstrapLoading.value = false
404 + }
405 +}
406 +
407 +async function loadStats() {
408 + if (!customer.value) {
409 + stats.value = null
410 + return
411 + }
412 + loading.value = true
413 + try {
414 + const res = await Api.aiAnalyst.getReviewStats(customer.value, 10)
415 + if (res.data.success) {
416 + stats.value = res.data
417 + } else {
418 + message.warning(res.data.message || "Failed to load stats")
419 + stats.value = null
420 + }
421 + } catch (err: unknown) {
422 + message.error(getApiErrorMessage(err as never) || "Failed to load stats")
423 + stats.value = null
424 + } finally {
425 + loading.value = false
426 + }
427 +}
428 +
429 +// Refetch stats on actual customer changes only. Wiring via @update:value on
430 +// the select is fragile — naive-ui can fire update:value when the options
431 +// prop identity churns, which would loop the stats endpoint. watch() only
432 +// fires on real value changes, so programmatic and user-driven picks behave
433 +// the same and options churn is ignored.
434 +watch(customer, () => {
435 + loadStats()
436 +})
437 +
438 +onBeforeMount(() => {
439 + bootstrapCustomers()
440 +})
441 +</script>
frontend/src/components/aiAnalyst/FeedbackMetricTile.vue new
+31
@@ -0,0 +1,31 @@
1 +<template>
2 + <CardEntity size="small" embedded>
3 + <template #default>
4 + <div class="flex flex-col gap-1">
5 + <div class="text-secondary text-xs tracking-wide uppercase">{{ label }}</div>
6 + <div
7 + class="text-2xl font-semibold"
8 + :class="{
9 + 'text-success': color === 'success',
10 + 'text-warning': color === 'warning',
11 + 'text-danger': color === 'danger'
12 + }"
13 + >
14 + {{ value }}
15 + </div>
16 + <div v-if="sub" class="text-secondary text-xs">{{ sub }}</div>
17 + </div>
18 + </template>
19 + </CardEntity>
20 +</template>
21 +
22 +<script setup lang="ts">
23 +import CardEntity from "@/components/common/cards/CardEntity.vue"
24 +
25 +defineProps<{
26 + label: string
27 + value: string
28 + sub?: string
29 + color?: "success" | "warning" | "danger"
30 +}>()
31 +</script>
frontend/src/components/aiAnalyst/FeedbackTemplateChoiceBar.vue new
+38
@@ -0,0 +1,38 @@
1 +<template>
2 + <div class="flex items-center gap-3">
3 + <div class="w-16 shrink-0 text-sm">{{ label }}</div>
4 + <n-progress
5 + type="line"
6 + :percentage="percentage"
7 + :show-indicator="false"
8 + :color="barColor"
9 + :height="10"
10 + class="grow"
11 + />
12 + <div class="text-secondary w-28 shrink-0 text-right text-sm">
13 + {{ count }} / {{ total }} ({{ percentage.toFixed(1) }}%)
14 + </div>
15 + </div>
16 +</template>
17 +
18 +<script setup lang="ts">
19 +import { NProgress } from "naive-ui"
20 +import { computed } from "vue"
21 +
22 +const props = defineProps<{
23 + label: string
24 + count: number
25 + total: number
26 + color: "success" | "warning" | "danger"
27 +}>()
28 +
29 +const percentage = computed(() => (props.total === 0 ? 0 : (props.count / props.total) * 100))
30 +
31 +// Map semantic colors to the brand palette. Naive's type="line" accepts raw
32 +// CSS colors via `color` prop — reach for the same tokens CardEntity uses.
33 +const barColor = computed(() => {
34 + if (props.color === "success") return "#16a34a"
35 + if (props.color === "warning") return "#f59e0b"
36 + return "#dc2626"
37 +})
38 +</script>
frontend/src/components/aiAnalyst/PalaceConsolidationDrawer.vue new
+339
@@ -0,0 +1,339 @@
1 +<template>
2 + <n-drawer v-model:show="showLocal" :width="640" placement="right">
3 + <n-drawer-content closable>
4 + <template #header>
5 + Palace consolidation
6 + <span v-if="customerCode" class="text-secondary ml-2 text-sm">· {{ customerCode }}</span>
7 + </template>
8 +
9 + <n-spin :show="loading" class="min-h-40">
10 + <div v-if="!customerCode" class="pt-6 text-center">
11 + <n-empty description="Select a customer first" />
12 + </div>
13 +
14 + <div v-else-if="!data && !loading" class="flex flex-col items-center gap-3 pt-6">
15 + <n-empty description="No digest yet" />
16 + </div>
17 +
18 + <div v-else-if="data" class="flex flex-col gap-4">
19 + <!-- Summary tiles -->
20 + <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
21 + <CardEntity size="small" embedded>
22 + <template #default>
23 + <div class="flex flex-col gap-1">
24 + <div class="text-secondary text-xs tracking-wide uppercase">Active</div>
25 + <div class="text-2xl font-semibold">{{ data.total_lessons }}</div>
26 + <div class="text-secondary text-xs">{{ data.total_pending }} pending</div>
27 + </div>
28 + </template>
29 + </CardEntity>
30 + <CardEntity size="small" embedded>
31 + <template #default>
32 + <div class="flex flex-col gap-1">
33 + <div class="text-secondary text-xs tracking-wide uppercase">Durable</div>
34 + <div class="text-2xl font-semibold text-success">{{ data.total_durable }}</div>
35 + <div class="text-secondary text-xs">never expire</div>
36 + </div>
37 + </template>
38 + </CardEntity>
39 + <CardEntity size="small" embedded>
40 + <template #default>
41 + <div class="flex flex-col gap-1">
42 + <div class="text-secondary text-xs tracking-wide uppercase">One-off</div>
43 + <div class="text-2xl font-semibold text-warning">{{ data.total_one_off }}</div>
44 + <div class="text-secondary text-xs">7-day TTL</div>
45 + </div>
46 + </template>
47 + </CardEntity>
48 + <CardEntity size="small" embedded>
49 + <template #default>
50 + <div class="flex flex-col gap-1">
51 + <div class="text-secondary text-xs tracking-wide uppercase">Duplicates</div>
52 + <div
53 + class="text-2xl font-semibold"
54 + :class="data.duplicate_candidates.length ? 'text-warning' : ''"
55 + >
56 + {{ data.duplicate_candidates.length }}
57 + </div>
58 + <div class="text-secondary text-xs">near-dupe pairs</div>
59 + </div>
60 + </template>
61 + </CardEntity>
62 + </div>
63 +
64 + <!-- Upcoming expirations -->
65 + <CardEntity v-if="data.upcoming_expirations.length" size="small" embedded highlighted>
66 + <template #headerMain>
67 + <Icon :name="WarningIcon" :size="14" class="text-warning mr-1" />
68 + Expiring soon ({{ data.upcoming_expirations.length }})
69 + </template>
70 + <template #default>
71 + <div class="flex flex-col gap-2">
72 + <div
73 + v-for="ls of data.upcoming_expirations"
74 + :key="ls.id"
75 + class="border-color bg-secondary rounded border p-2 text-sm"
76 + >
77 + <div class="mb-1 flex flex-wrap items-center gap-2">
78 + <Badge type="splitted" bright color="warning">
79 + <template #label>{{ ls.lesson_type }}</template>
80 + <template #value>{{ expiryLabel(ls.days_until_expiry) }}</template>
81 + </Badge>
82 + <span class="text-secondary text-xs">id {{ ls.id }}</span>
83 + </div>
84 + <div class="break-words whitespace-pre-wrap">{{ ls.lesson_text }}</div>
85 + </div>
86 + </div>
87 + </template>
88 + </CardEntity>
89 +
90 + <!-- Near-duplicate pairs -->
91 + <CardEntity v-if="data.duplicate_candidates.length" size="small" embedded>
92 + <template #headerMain>
93 + Near-duplicate candidates ({{ data.duplicate_candidates.length }})
94 + </template>
95 + <template #default>
96 + <div class="flex flex-col gap-2">
97 + <div
98 + v-for="pair of data.duplicate_candidates"
99 + :key="`${pair.lesson_a_id}-${pair.lesson_b_id}`"
100 + class="border-color bg-secondary rounded border p-2 text-sm"
101 + >
102 + <div class="mb-1 flex flex-wrap items-center gap-2">
103 + <Badge
104 + type="splitted"
105 + bright
106 + :color="simColor(pair.similarity)"
107 + >
108 + <template #label>{{ pair.room }}</template>
109 + <template #value>{{ Math.round(pair.similarity * 100) }}%</template>
110 + </Badge>
111 + </div>
112 + <div class="mb-1">
113 + <span class="text-secondary text-xs">#{{ pair.lesson_a_id }}</span>
114 + <span class="ml-1 break-words whitespace-pre-wrap">{{ pair.lesson_a_text }}</span>
115 + </div>
116 + <div>
117 + <span class="text-secondary text-xs">#{{ pair.lesson_b_id }}</span>
118 + <span class="ml-1 break-words whitespace-pre-wrap">{{ pair.lesson_b_text }}</span>
119 + </div>
120 + </div>
121 + </div>
122 + </template>
123 + </CardEntity>
124 +
125 + <!-- Per-room breakdown -->
126 + <CardEntity size="small" embedded>
127 + <template #headerMain>By room</template>
128 + <template #default>
129 + <n-empty
130 + v-if="!data.rooms.length"
131 + description="No active lessons"
132 + class="min-h-20 justify-center"
133 + />
134 + <n-collapse v-else>
135 + <n-collapse-item
136 + v-for="group of data.rooms"
137 + :key="group.room"
138 + :name="group.room"
139 + >
140 + <template #header>
141 + <div class="flex items-center gap-2">
142 + <span class="font-medium">{{ group.room }}</span>
143 + <Badge type="splitted">
144 + <template #label>Total</template>
145 + <template #value>{{ group.total }}</template>
146 + </Badge>
147 + <Badge type="splitted" color="success">
148 + <template #label>Durable</template>
149 + <template #value>{{ group.durable }}</template>
150 + </Badge>
151 + <Badge type="splitted" color="warning">
152 + <template #label>One-off</template>
153 + <template #value>{{ group.one_off }}</template>
154 + </Badge>
155 + </div>
156 + </template>
157 + <div class="flex flex-col gap-2">
158 + <div
159 + v-for="ls of group.lessons"
160 + :key="ls.id"
161 + class="border-color bg-secondary rounded border p-2 text-sm"
162 + >
163 + <div class="mb-1 flex flex-wrap items-center gap-2">
164 + <Badge
165 + type="splitted"
166 + :color="ls.durability === 'durable' ? 'success' : 'warning'"
167 + >
168 + <template #label>{{ ls.durability }}</template>
169 + <template #value>
170 + {{ ls.durability === "one_off"
171 + ? expiryLabel(ls.days_until_expiry)
172 + : "∞" }}
173 + </template>
174 + </Badge>
175 + <Badge type="splitted" :color="statusColor(ls.status)">
176 + <template #label>Status</template>
177 + <template #value>{{ ls.status }}</template>
178 + </Badge>
179 + <span class="text-secondary text-xs">
180 + id {{ ls.id }} · {{ formatDate(ls.created_at, "MMM D") }}
181 + </span>
182 + </div>
183 + <div class="break-words whitespace-pre-wrap">{{ ls.lesson_text }}</div>
184 + </div>
185 + </div>
186 + </n-collapse-item>
187 + </n-collapse>
188 + </template>
189 + </CardEntity>
190 +
191 + <div class="text-secondary text-xs">
192 + Generated {{ formatDate(data.generated_at, "MMM D, YYYY HH:mm") }} UTC
193 + </div>
194 + </div>
195 + </n-spin>
196 +
197 + <template #footer>
198 + <div class="flex w-full items-center justify-between gap-2">
199 + <div class="text-secondary text-xs">
200 + <template v-if="data">
201 + {{ data.total_lessons }} lesson(s) · {{ data.rooms.length }} room(s)
202 + </template>
203 + </div>
204 + <div class="flex items-center gap-2">
205 + <n-button
206 + size="small"
207 + :disabled="!data || !data.markdown"
208 + @click="copyMarkdown"
209 + >
210 + <template #icon>
211 + <Icon :name="CopyIcon" :size="14" />
212 + </template>
213 + Copy markdown
214 + </n-button>
215 + <n-button
216 + size="small"
217 + :disabled="!customerCode || loading"
218 + @click="load()"
219 + >
220 + <template #icon>
221 + <Icon :name="RefreshIcon" :size="14" />
222 + </template>
223 + Refresh
224 + </n-button>
225 + </div>
226 + </div>
227 + </template>
228 + </n-drawer-content>
229 + </n-drawer>
230 +</template>
231 +
232 +<script setup lang="ts">
233 +import type { PalaceConsolidation } from "@/types/aiAnalyst.d"
234 +import { NButton, NCollapse, NCollapseItem, NDrawer, NDrawerContent, NEmpty, NSpin, useMessage } from "naive-ui"
235 +import { computed, ref, watch } from "vue"
236 +import Api from "@/api"
237 +import Badge from "@/components/common/Badge.vue"
238 +import CardEntity from "@/components/common/cards/CardEntity.vue"
239 +import Icon from "@/components/common/Icon.vue"
240 +import { getApiErrorMessage } from "@/utils"
241 +import { formatDate } from "@/utils/format"
242 +
243 +const props = defineProps<{
244 + show: boolean
245 + customerCode: string | null
246 +}>()
247 +
248 +const emit = defineEmits<{
249 + (e: "update:show", value: boolean): void
250 +}>()
251 +
252 +const RefreshIcon = "carbon:renew"
253 +const CopyIcon = "carbon:copy"
254 +const WarningIcon = "carbon:warning"
255 +
256 +const message = useMessage()
257 +
258 +// v-model:show bridge — local ref mirrors the prop so the drawer's
259 +// internal close button also propagates back to the parent.
260 +const showLocal = computed({
261 + get: () => props.show,
262 + set: (v: boolean) => emit("update:show", v)
263 +})
264 +
265 +const loading = ref(false)
266 +const data = ref<PalaceConsolidation | null>(null)
267 +
268 +function simColor(sim: number): "success" | "warning" | "danger" {
269 + // Similarity is always >= threshold (0.7) when flagged — calibrate bands
270 + // for "probably rewrite" vs "review manually" at a glance.
271 + if (sim >= 0.9) return "danger"
272 + if (sim >= 0.8) return "warning"
273 + return "success"
274 +}
275 +
276 +function statusColor(status: string): "success" | "warning" | "danger" | undefined {
277 + if (status === "ingested") return "success"
278 + if (status === "pending") return "warning"
279 + if (status === "failed") return "danger"
280 + return undefined
281 +}
282 +
283 +function expiryLabel(days: number | null): string {
284 + if (days == null) return "—"
285 + if (days <= 0) return "due"
286 + if (days === 1) return "1d"
287 + return `${days}d`
288 +}
289 +
290 +async function load() {
291 + if (!props.customerCode) {
292 + data.value = null
293 + return
294 + }
295 + loading.value = true
296 + try {
297 + const res = await Api.aiAnalyst.getPalaceConsolidation(props.customerCode)
298 + if (res.data.success) {
299 + data.value = res.data
300 + } else {
301 + message.warning(res.data.message || "Failed to build consolidation")
302 + data.value = null
303 + }
304 + } catch (err: unknown) {
305 + message.error(getApiErrorMessage(err as never) || "Failed to build consolidation")
306 + data.value = null
307 + } finally {
308 + loading.value = false
309 + }
310 +}
311 +
312 +async function copyMarkdown() {
313 + if (!data.value?.markdown) return
314 + try {
315 + await navigator.clipboard.writeText(data.value.markdown)
316 + message.success("Markdown copied to clipboard")
317 + } catch {
318 + message.error("Clipboard unavailable — select + copy from the drawer body")
319 + }
320 +}
321 +
322 +// Auto-load on open (and reload if customer changes while open).
323 +// NB: don't destructure oldValue in the handler — on `immediate: true` the
324 +// initial oldValue is `undefined`, which throws on tuple destructure and
325 +// crashes setup, which Vue's error-recovery retries → infinite remount loop.
326 +watch(
327 + () => [props.show, props.customerCode] as const,
328 + (curr, prev) => {
329 + const [show, code] = curr
330 + const prevShow = prev ? prev[0] : false
331 + if (show && code && (!prevShow || data.value?.customer_code !== code)) {
332 + load()
333 + }
334 + // When show flips back to false we keep the previous data around so
335 + // re-opening feels instant; reset only when the customer changes.
336 + },
337 + { immediate: true }
338 +)
339 +</script>
frontend/src/components/aiAnalyst/ReplayModal.vue new
+157
@@ -0,0 +1,157 @@
1 +<template>
2 + <n-modal
3 + v-model:show="showLocal"
4 + :style="{ maxWidth: 'min(720px, 90vw)' }"
5 + preset="card"
6 + title="Replay with different template"
7 + :bordered="false"
8 + segmented
9 + >
10 + <n-spin :show="loading">
11 + <div class="flex flex-col gap-4">
12 + <div class="text-secondary text-sm">
13 + Re-runs the investigation for alert
14 + <code class="text-primary">#{{ report.alert_id }}</code> with a forced template. The
15 + replay creates its own new job and report via Talon — nothing on this report is
16 + modified.
17 + </div>
18 +
19 + <div class="flex flex-wrap items-center gap-3">
20 + <Badge type="splitted" bright>
21 + <template #label>Customer</template>
22 + <template #value>{{ report.customer_code }}</template>
23 + </Badge>
24 + </div>
25 +
26 + <div>
27 + <div class="mb-2 font-medium">Choose a template</div>
28 + <div v-if="!loading && !templates.length">
29 + <n-empty description="No templates available" class="min-h-20 justify-center" />
30 + </div>
31 + <n-radio-group
32 + v-else
33 + v-model:value="selectedFilename"
34 + class="flex max-h-80 w-full flex-col gap-2 overflow-y-auto pr-1"
35 + >
36 + <n-radio
37 + v-for="tpl of templates"
38 + :key="tpl.filename"
39 + :value="tpl.filename"
40 + class="border-color hover:border-primary w-full rounded border p-3"
41 + :class="selectedFilename === tpl.filename ? 'border-primary bg-primary/5' : ''"
42 + >
43 + <div class="flex w-full flex-col gap-1">
44 + <div class="flex items-center justify-between gap-3">
45 + <span class="font-medium">{{ tpl.filename }}</span>
46 + <span class="text-secondary text-xs">{{ formatBytes(tpl.size_bytes) }}</span>
47 + </div>
48 + <span class="text-secondary text-xs">
49 + updated {{ formatDate(tpl.modified_at, "MMM D, YYYY HH:mm") }}
50 + </span>
51 + </div>
52 + </n-radio>
53 + </n-radio-group>
54 + </div>
55 + </div>
56 + </n-spin>
57 +
58 + <template #action>
59 + <div class="flex w-full items-center justify-end gap-3">
60 + <n-button :disabled="submitting" @click="showLocal = false">Cancel</n-button>
61 + <n-button
62 + type="primary"
63 + :disabled="!selectedFilename || submitting"
64 + :loading="submitting"
65 + @click="handleReplay"
66 + >
67 + Replay
68 + </n-button>
69 + </div>
70 + </template>
71 + </n-modal>
72 +</template>
73 +
74 +<script setup lang="ts">
75 +import type { AiAnalystReport } from "@/types/aiAnalyst.d"
76 +import type { TalonTemplate } from "@/types/talon.d"
77 +import { NButton, NEmpty, NModal, NRadio, NRadioGroup, NSpin, useMessage } from "naive-ui"
78 +import { computed, ref, watch } from "vue"
79 +import Api from "@/api"
80 +import Badge from "@/components/common/Badge.vue"
81 +import { formatBytes, formatDate } from "@/utils/format"
82 +
83 +const props = defineProps<{
84 + show: boolean
85 + report: AiAnalystReport
86 +}>()
87 +
88 +const emit = defineEmits<{
89 + (e: "update:show", v: boolean): void
90 + (e: "replayed", data: Record<string, unknown> | undefined): void
91 +}>()
92 +
93 +const message = useMessage()
94 +const loading = ref(false)
95 +const submitting = ref(false)
96 +const templates = ref<TalonTemplate[]>([])
97 +const selectedFilename = ref<string | null>(null)
98 +
99 +const showLocal = computed({
100 + get: () => props.show,
101 + set: (v: boolean) => emit("update:show", v)
102 +})
103 +
104 +async function loadTemplates() {
105 + loading.value = true
106 + selectedFilename.value = null
107 + try {
108 + const res = await Api.talon.getTemplates()
109 + if (res.data.success) {
110 + templates.value = res.data.templates || []
111 + } else {
112 + message.warning(res.data.message || "Failed to load templates")
113 + templates.value = []
114 + }
115 + } catch (err: unknown) {
116 + const e = err as { response?: { data?: { message?: string } }; message?: string }
117 + message.error(e.response?.data?.message || e.message || "Failed to load templates")
118 + templates.value = []
119 + } finally {
120 + loading.value = false
121 + }
122 +}
123 +
124 +async function handleReplay() {
125 + if (!selectedFilename.value) return
126 + submitting.value = true
127 + try {
128 + const res = await Api.aiAnalyst.replayReport(props.report.id, {
129 + template_override: selectedFilename.value,
130 + customer_code: props.report.customer_code,
131 + sender: "copilot-replay"
132 + })
133 + if (res.data.success) {
134 + message.success(res.data.message || "Replay triggered")
135 + emit("replayed", res.data.data)
136 + showLocal.value = false
137 + } else {
138 + message.warning(res.data.message || "Failed to trigger replay")
139 + }
140 + } catch (err: unknown) {
141 + const e = err as { response?: { data?: { message?: string } }; message?: string }
142 + message.error(e.response?.data?.message || e.message || "Failed to trigger replay")
143 + } finally {
144 + submitting.value = false
145 + }
146 +}
147 +
148 +// Fetch templates each time the modal is opened — fresh mtime + preview,
149 +// and covers the case where the user adds a template via the palace flow.
150 +watch(
151 + () => props.show,
152 + v => {
153 + if (v) loadTemplates()
154 + },
155 + { immediate: true }
156 +)
157 +</script>
frontend/src/types/aiAnalyst.d.ts
+174
@@ -47,3 +47,177 @@ export interface AlertWithReport {
47 alert_creation_time: string
48 report: AiAnalystReport
49 }
50 +
51 +// --- Review / Palace / Replay ---
52 +
53 +export type OverallVerdict = "up" | "down"
54 +export type TemplateChoice = "correct" | "wrong" | "partial"
55 +export type LessonType = "environment" | "false_positives" | "assets" | "threat_intel" | "alerts"
56 +export type Durability = "one_off" | "durable"
57 +export type PalaceLessonStatus = "pending" | "ingested" | "failed" | "expired"
58 +
59 +export interface AiAnalystIocReview {
60 + id: number
61 + review_id: number
62 + ioc_id: number
63 + verdict_correct: boolean
64 + note: string | null
65 + created_at: string
66 +}
67 +
68 +export interface AiAnalystReview {
69 + id: number
70 + report_id: number
71 + alert_id: number
72 + customer_code: string
73 + reviewer_user_id: number
74 + overall_verdict: OverallVerdict | null
75 + template_choice: TemplateChoice | null
76 + template_used: string | null
77 + rating_instructions: number | null
78 + rating_artifacts: number | null
79 + rating_severity: number | null
80 + missing_steps: string | null
81 + suggested_edits: string | null
82 + created_at: string
83 + updated_at: string | null
84 + ioc_reviews: AiAnalystIocReview[]
85 +}
86 +
87 +export interface IocVerdictCorrection {
88 + ioc_id: number
89 + verdict_correct: boolean
90 + note?: string
91 +}
92 +
93 +export interface SubmitReviewPayload {
94 + overall_verdict?: OverallVerdict
95 + template_choice?: TemplateChoice
96 + template_used?: string
97 + rating_instructions?: number
98 + rating_artifacts?: number
99 + rating_severity?: number
100 + missing_steps?: string
101 + suggested_edits?: string
102 + ioc_reviews?: IocVerdictCorrection[]
103 +}
104 +
105 +export interface AiAnalystPalaceLesson {
106 + id: number
107 + review_id: number | null
108 + customer_code: string
109 + lesson_type: string
110 + lesson_text: string
111 + durability: string
112 + status: string
113 + ingested_at: string | null
114 + created_at: string
115 +}
116 +
117 +export interface QueuePalaceLessonPayload {
118 + customer_code: string
119 + lesson_type: LessonType
120 + lesson_text: string
121 + durability?: Durability
122 + review_id?: number
123 +}
124 +
125 +export interface ReplayPayload {
126 + template_override: string
127 + customer_code: string
128 + sender?: string
129 +}
130 +
131 +export interface PalaceSearchHit {
132 + id: string | null
133 + room: string | null
134 + wing: string | null
135 + text: string | null
136 + source_file: string | null
137 + score: number | null
138 + metadata: Record<string, unknown> | null
139 +}
140 +
141 +// --- Feedback dashboard ---
142 +
143 +export interface ReviewStatsTemplate {
144 + template_used: string | null
145 + total: number
146 + thumbs_up: number
147 + thumbs_down: number
148 + correct: number
149 + partial: number
150 + wrong: number
151 + avg_rating_instructions: number | null
152 + avg_rating_artifacts: number | null
153 + avg_rating_severity: number | null
154 +}
155 +
156 +export interface ReviewStatsIocAccuracy {
157 + total: number
158 + correct: number
159 + incorrect: number
160 + accuracy_pct: number | null
161 +}
162 +
163 +export interface AiAnalystReviewStats {
164 + customer_code: string
165 + total_reviews: number
166 + thumbs_up: number
167 + thumbs_down: number
168 + thumbs_up_pct: number | null
169 + template_choice_correct: number
170 + template_choice_partial: number
171 + template_choice_wrong: number
172 + avg_rating_instructions: number | null
173 + avg_rating_artifacts: number | null
174 + avg_rating_severity: number | null
175 + ioc_accuracy: ReviewStatsIocAccuracy
176 + per_template: ReviewStatsTemplate[]
177 + recent_reviews: AiAnalystReview[]
178 +}
179 +
180 +// --- Palace consolidation (Step 21.B) ---
181 +
182 +export interface PalaceConsolidationLesson {
183 + id: number
184 + lesson_type: string
185 + lesson_text: string
186 + durability: string
187 + status: string
188 + drawer_id: string | null
189 + created_at: string
190 + ingested_at: string | null
191 + days_until_expiry: number | null
192 +}
193 +
194 +export interface PalaceConsolidationRoomGroup {
195 + room: string
196 + total: number
197 + durable: number
198 + one_off: number
199 + lessons: PalaceConsolidationLesson[]
200 +}
201 +
202 +export interface PalaceConsolidationDuplicatePair {
203 + room: string
204 + lesson_a_id: number
205 + lesson_b_id: number
206 + lesson_a_text: string
207 + lesson_b_text: string
208 + similarity: number
209 +}
210 +
211 +export interface PalaceConsolidation {
212 + customer_code: string
213 + generated_at: string
214 + total_lessons: number
215 + total_durable: number
216 + total_one_off: number
217 + total_pending: number
218 + total_ingested: number
219 + upcoming_expirations: PalaceConsolidationLesson[]
220 + rooms: PalaceConsolidationRoomGroup[]
221 + duplicate_candidates: PalaceConsolidationDuplicatePair[]
222 + markdown: string
223 +}
frontend/src/types/talon.d.ts
+7
@@ -13,6 +13,13 @@ export interface TalonStatusData {
13 [key: string]: unknown
14 }
15
16 +export interface TalonTemplate {
17 + filename: string
18 + size_bytes: number
19 + modified_at: string
20 + first_line: string | null
21 +}
22 +
23 export interface TalonJobData {
24 id: string
25 alert_id: number
frontend/src/views/AiAnalyst.vue
+4
@@ -10,6 +10,9 @@
10 <n-tab-pane name="reports" tab="Reports" display-directive="show:lazy">
11 <AlertsReportsList />
12 </n-tab-pane>
13 + <n-tab-pane name="feedback" tab="Feedback" display-directive="show:lazy">
14 + <FeedbackDashboard />
15 + </n-tab-pane>
16 </n-tabs>
17 </div>
18 </template>
@@ -18,6 +21,7 @@
21 import { NTabPane, NTabs } from "naive-ui"
22 import { ref } from "vue"
23 import AlertsReportsList from "@/components/aiAnalyst/AlertsReportsList.vue"
24 +import FeedbackDashboard from "@/components/aiAnalyst/FeedbackDashboard.vue"
25 import TalonOverview from "@/components/aiAnalyst/TalonOverview.vue"
26 import TalonChat from "@/components/talonChat/TalonChatContainer.vue"
27