+
+
+
+
+
+ mdi-sparkles
+ Generate AI tag suggestions
+
+
+
+
+
+
+
+
+ mdi-alert
+ {{ suggestionsError }}
+
+ mdi-refresh
+ Try Again
+
+
+
+
+
+
+
+
+ GenAI suggests the following tags:
+
+
+
+ {{ suggestionsExpanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
+
+
+
+
+
+
+
+
+
+ {{ getTagType(group.tag_type_id).name }}:
+
+
+
+
+ mdi-{{ getTagType(group.tag_type_id).icon }}
+
+ {{ tag.name }}
+ mdi-plus
+
+
+
+
+ mdi-lightbulb-on-outline
+ Tip: Hover over a suggested tag to see why it was recommended.
+
+
+
+
+
+
Required
@@ -133,37 +262,22 @@
diff --git a/src/dispatch/static/dispatch/src/tag/api.js b/src/dispatch/static/dispatch/src/tag/api.js
index a4d202a4b8cd..da13b855f753 100644
--- a/src/dispatch/static/dispatch/src/tag/api.js
+++ b/src/dispatch/static/dispatch/src/tag/api.js
@@ -13,8 +13,12 @@ export default {
return API.get(`${resource}/${tagId}`)
},
- getRecommendations(model, modelId) {
- return API.get(`/${resource}/recommendations/${model}/${modelId}`)
+ getRecommendationsCase(projectId, caseId) {
+ return API.get(`/${resource}/recommendations/${projectId}/case/${caseId}`)
+ },
+
+ getRecommendationsIncident(projectId, incidentId) {
+ return API.get(`/${resource}/recommendations/${projectId}/incident/${incidentId}`)
},
create(payload) {
diff --git a/src/dispatch/static/dispatch/src/tag/store.js b/src/dispatch/static/dispatch/src/tag/store.js
index 6118f5a1e85b..f039b1c732c4 100644
--- a/src/dispatch/static/dispatch/src/tag/store.js
+++ b/src/dispatch/static/dispatch/src/tag/store.js
@@ -3,6 +3,7 @@ import { debounce } from "lodash"
import SearchUtils from "@/search/utils"
import TagApi from "@/tag/api"
+import TagTypeApi from "@/tag_type/api"
const getDefaultSelectedState = () => {
return {
@@ -42,10 +43,24 @@ const state = {
descending: [false],
filters: {
project: [],
+ tag_type: [],
+ discoverable: [],
},
},
loading: false,
},
+ suggestedTags: [],
+ selectedItems: [],
+ validationError: null,
+ tagTypes: {},
+ groups: [],
+ loading: false,
+ more: false,
+ total: 0,
+ suggestionsLoading: false,
+ suggestionsGenerated: false,
+ suggestionsError: null,
+ tagSuggestions: [],
}
const getters = {
@@ -128,6 +143,267 @@ const actions = {
)
})
},
+ async fetchSuggestedTags({ commit }, suggestedTagData) {
+ // Fetch all tag and tag_type data needed for the suggestions
+ const tagTypeIds = suggestedTagData.map((g) => g.tag_type_id)
+ const tagIds = suggestedTagData.flatMap((g) => g.tags.map((t) => t.id))
+ const tagFilterOptions = {
+ filters: {
+ tagIdFilter: tagIds.map((id) => ({ model: "Tag", field: "id", op: "==", value: id })),
+ tagTypeIdFilter: tagTypeIds.map((id) => ({
+ model: "TagType",
+ field: "id",
+ op: "==",
+ value: id,
+ })),
+ },
+ itemsPerPage: 100,
+ }
+ const params = SearchUtils.createParametersFromTableOptions({ ...tagFilterOptions })
+ const tagResp = await TagApi.getAll(params)
+ const tags = tagResp.data.items
+ // Group tags by tag_type_id
+ const tagTypeMap = {}
+ tags.forEach((tag) => {
+ if (tag.tag_type && tag.tag_type.id) {
+ tagTypeMap[tag.tag_type.id] = tag.tag_type
+ }
+ })
+ // Also ensure all tag_types are present (in case some are not attached to tags)
+ suggestedTagData.forEach((group) => {
+ if (!tagTypeMap[group.tag_type_id]) {
+ tagTypeMap[group.tag_type_id] = {
+ id: group.tag_type_id,
+ name: "",
+ icon: "",
+ color: "#1976d2",
+ }
+ }
+ })
+ // Build the suggestion structure for rendering
+ const result = suggestedTagData.map((group) => {
+ const tag_type = tagTypeMap[group.tag_type_id]
+ return {
+ tag_type,
+ tags: group.tags.map((t) => {
+ const tag = tags.find((tg) => tg.id === t.id)
+ return {
+ ...t,
+ tag,
+ }
+ }),
+ }
+ })
+ commit("SET_SUGGESTED_TAGS", result)
+ },
+ addSuggestedTag({ commit, state }, tagObj) {
+ if (!tagObj || !tagObj.id) return
+ if (!state.selectedItems.some((item) => item.id === tagObj.id)) {
+ commit("ADD_SELECTED_TAG", tagObj)
+ }
+ },
+ removeTag({ commit }, tagId) {
+ commit("REMOVE_SELECTED_TAG", tagId)
+ },
+ validateTags({ commit }, { value, groups, currentProject }) {
+ // project_id logic
+ const project_id = currentProject?.id || 0
+ let all_tags_in_project = false
+ if (project_id) {
+ all_tags_in_project = value.every((tag) => tag.project?.id == project_id)
+ } else {
+ const project_name = currentProject?.name
+ if (!project_name) {
+ commit("SET_VALIDATION_ERROR", true)
+ return
+ }
+ all_tags_in_project = value.every((tag) => tag.project?.name == project_name)
+ }
+ if (all_tags_in_project) {
+ if (!areRequiredTagsSelected(value, groups)) {
+ const required_tag_types = groups
+ .filter((tag_type) => tag_type.isRequired)
+ .map((tag_type) => tag_type.label)
+ commit(
+ "SET_VALIDATION_ERROR",
+ `Please select at least one tag from each required category (${required_tag_types.join(
+ ", "
+ )})`
+ )
+ } else {
+ commit("SET_VALIDATION_ERROR", null)
+ }
+ } else {
+ commit("SET_VALIDATION_ERROR", "Only tags in selected project are allowed")
+ }
+ },
+ async fetchEligibleTagTypes() {
+ // Fetch all tag types where any discoverable_* is true
+ const discoverableFields = [
+ "discoverable_incident",
+ "discoverable_case",
+ "discoverable_signal",
+ "discoverable_query",
+ "discoverable_source",
+ "discoverable_document",
+ ]
+ const orFilters = discoverableFields.map((field) => ({ field, op: "==", value: true }))
+ const params = {
+ filters: { or: orFilters },
+ itemsPerPage: 5000, // adjust as needed
+ }
+ const resp = await TagTypeApi.getAll(params)
+ return resp.data.items.map((tt) => tt.id)
+ },
+
+ async fetchAllTagsWithEligibleTypes({ commit, dispatch }, { project }) {
+ // 1. Fetch eligible tag type ids
+ const eligibleTagTypeIds = await dispatch("fetchEligibleTagTypes")
+ // 2. Fetch tags for this project
+ const tagFilterOptions = {
+ filters: {
+ project: [{ model: "Project", field: "name", op: "==", value: project.name }],
+ tagTypeIdFilter: eligibleTagTypeIds.map((id) => ({
+ model: "TagType",
+ field: "id",
+ op: "==",
+ value: id,
+ })),
+ tagFilter: [{ model: "Tag", field: "discoverable", op: "==", value: "true" }],
+ },
+ itemsPerPage: 5000, // adjust as needed
+ }
+ const params = SearchUtils.createParametersFromTableOptions({ ...tagFilterOptions })
+ const tagResp = await TagApi.getAll(params)
+ // 3. Filter tags client-side as a safeguard
+ const tags = tagResp.data.items.filter((tag) => eligibleTagTypeIds.includes(tag.tag_type.id))
+
+ commit("SET_TABLE_ROWS", { items: tags, total: tags.length })
+ return tags
+ },
+
+ async fetchTags({ commit }, { project, model }) {
+ if (!project) return
+
+ commit("SET_LOADING", true)
+
+ let filterOptions = {
+ q: null,
+ itemsPerPage: 500,
+ sortBy: ["tag_type.name"],
+ descending: [false],
+ filters: {
+ project: [{ model: "Project", field: "name", op: "==", value: project.name }],
+ tagFilter: [{ model: "Tag", field: "discoverable", op: "==", value: "true" }],
+ },
+ }
+
+ if (model) {
+ filterOptions.filters.tagTypeFilter = [
+ { model: "TagType", field: "discoverable_" + model, op: "==", value: "true" },
+ ]
+ }
+
+ filterOptions = SearchUtils.createParametersFromTableOptions(filterOptions)
+
+ try {
+ const response = await TagApi.getAll(filterOptions)
+ commit("SET_TABLE_ROWS", response.data)
+ commit("SET_GROUPS", convertData(response.data.items))
+ commit("SET_LOADING", false)
+ return response.data.items
+ } catch (error) {
+ console.error("Error fetching tags:", error)
+ commit("SET_LOADING", false)
+ throw error
+ }
+ },
+
+ async fetchTagTypes({ commit }) {
+ try {
+ const resp = await TagTypeApi.getAll({ itemsPerPage: 5000 })
+ const tagTypes = Object.fromEntries(resp.data.items.map((tt) => [tt.id, tt]))
+
+ // Add sample tag types for demo purposes if they don't exist
+ if (!tagTypes[135]) {
+ tagTypes[135] = {
+ id: 135,
+ name: "MITRE Tactics",
+ color: "#1976d2",
+ icon: "bullseye-arrow",
+ }
+ }
+ if (!tagTypes[136]) {
+ tagTypes[136] = {
+ id: 136,
+ name: "MITRE Techniques",
+ color: "#388e3c",
+ icon: "tools",
+ }
+ }
+
+ commit("SET_TAG_TYPES", tagTypes)
+ return tagTypes
+ } catch (error) {
+ console.error("Error fetching tag types:", error)
+ throw error
+ }
+ },
+
+ async generateSuggestions({ commit }, { projectId, modelId, modelType = "incident" }) {
+ commit("SET_SUGGESTIONS_LOADING", true)
+ commit("SET_SUGGESTIONS_ERROR", null)
+
+ try {
+ let response
+ if (modelType === "case") {
+ response = await TagApi.getRecommendationsCase(projectId, modelId)
+ } else {
+ response = await TagApi.getRecommendationsIncident(projectId, modelId)
+ }
+
+ const errorMessage = response.data?.error_message || response.error_message
+ if (errorMessage) {
+ commit("SET_SUGGESTIONS_ERROR", errorMessage)
+ commit("SET_TAG_SUGGESTIONS", [])
+ return
+ }
+
+ const suggestions = response.data?.recommendations || response.recommendations || []
+ commit("SET_TAG_SUGGESTIONS", Array.isArray(suggestions) ? suggestions : [])
+ commit("SET_SUGGESTIONS_GENERATED", true)
+ } catch (error) {
+ console.error("Error generating AI suggestions:", error)
+ commit(
+ "SET_SUGGESTIONS_ERROR",
+ "Failed to generate AI tag suggestions. Please try again later."
+ )
+ commit("SET_TAG_SUGGESTIONS", [])
+ } finally {
+ commit("SET_SUGGESTIONS_LOADING", false)
+ commit("SET_SUGGESTIONS_GENERATED", true)
+ }
+ },
+
+ resetSuggestions({ commit }) {
+ commit("SET_SUGGESTIONS_GENERATED", false)
+ commit("SET_SUGGESTIONS_ERROR", null)
+ },
+
+ getTagType({ state }, tagTypeId) {
+ return state.tagTypes[tagTypeId] || {}
+ },
+}
+
+function areRequiredTagsSelected(sel, tagTypes) {
+ for (let i = 0; i < tagTypes.length; i++) {
+ if (tagTypes[i].isRequired) {
+ if (!sel.some((item) => item.tag_type?.id === tagTypes[i]?.id)) {
+ return false
+ }
+ }
+ }
+ return true
}
const mutations = {
@@ -156,6 +432,76 @@ const mutations = {
state.selected = { ...getDefaultSelectedState() }
state.selected.project = project
},
+ SET_SUGGESTED_TAGS(state, tags) {
+ state.suggestedTags = tags
+ },
+ ADD_SELECTED_TAG(state, tag) {
+ state.selectedItems = [...(state.selectedItems || []), tag]
+ },
+ REMOVE_SELECTED_TAG(state, tagId) {
+ state.selectedItems = (state.selectedItems || []).filter((item) => item.id !== tagId)
+ },
+ SET_VALIDATION_ERROR(state, error) {
+ state.validationError = error
+ },
+ SET_LOADING(state, value) {
+ state.loading = value
+ },
+ SET_MORE(state, value) {
+ state.more = value
+ },
+ SET_TOTAL(state, value) {
+ state.total = value
+ },
+ SET_GROUPS(state, groups) {
+ state.groups = groups
+ },
+ SET_TAG_TYPES(state, types) {
+ state.tagTypes = types
+ },
+ SET_SUGGESTIONS_LOADING(state, value) {
+ state.suggestionsLoading = value
+ },
+ SET_SUGGESTIONS_GENERATED(state, value) {
+ state.suggestionsGenerated = value
+ },
+ SET_SUGGESTIONS_ERROR(state, error) {
+ state.suggestionsError = error
+ },
+ SET_TAG_SUGGESTIONS(state, suggestions) {
+ state.tagSuggestions = suggestions
+ },
+}
+
+// Helper function for converting data
+function convertData(data) {
+ return data.reduce((r, a) => {
+ const tagType = a.tag_type
+ const hasAnyDiscoverability =
+ tagType.discoverable_incident ||
+ tagType.discoverable_case ||
+ tagType.discoverable_signal ||
+ tagType.discoverable_query ||
+ tagType.discoverable_source ||
+ tagType.discoverable_document
+
+ if (!hasAnyDiscoverability) return r
+
+ if (!r[a.tag_type.id]) {
+ r[a.tag_type.id] = {
+ id: a.tag_type.id,
+ icon: a.tag_type.icon,
+ label: a.tag_type.name,
+ desc: a.tag_type.description,
+ color: a.tag_type.color,
+ isRequired: a.tag_type.required,
+ isExclusive: a.tag_type.exclusive,
+ menuItems: [],
+ }
+ }
+ r[a.tag_type.id].menuItems.push(a)
+ return r
+ }, Object.create(null))
}
export default {
diff --git a/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue b/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
index dd0a659e6d2a..4fa297a5b60e 100644
--- a/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/tag_type/NewEditSheet.vue
@@ -170,6 +170,23 @@
+
+
+
+
+
+
+ mdi-information
+
+
+ If activated, GenAI will provide tag suggestions for this tag type in the UI.
+
+
+
@@ -217,6 +234,7 @@ export default {
"selected.exclusive",
"selected.required",
"selected.use_for_project_folder",
+ "selected.genai_suggestions",
"selected.loading",
]),
...mapFields("tag_type", {
diff --git a/src/dispatch/static/dispatch/src/tag_type/Table.vue b/src/dispatch/static/dispatch/src/tag_type/Table.vue
index 355398ba680e..a416f1ec01f3 100644
--- a/src/dispatch/static/dispatch/src/tag_type/Table.vue
+++ b/src/dispatch/static/dispatch/src/tag_type/Table.vue
@@ -33,28 +33,31 @@
:loading="loading"
loading-text="Loading... Please wait"
>
-
+
-
-
+
+
mdi-dots-vertical
-
+
View / Edit
-
- {{ combine(item) }}
+
+ {{ combine(slotProps.item) }}
-
-
+
+
-
-
+
+
+
+
+
@@ -94,6 +97,7 @@ export default {
{ title: "Discoverability", value: "discoverability", sortable: false },
{ title: "Required", value: "required", sortable: false },
{ title: "Exclusive", value: "exclusive", sortable: false },
+ { title: "GenAI Suggestions", value: "genai_suggestions", sortable: false },
{ title: "", key: "data-table-actions", sortable: false, align: "end" },
],
}
diff --git a/src/dispatch/tag/models.py b/src/dispatch/tag/models.py
index 58d0ea240c1f..726e68898841 100644
--- a/src/dispatch/tag/models.py
+++ b/src/dispatch/tag/models.py
@@ -60,3 +60,26 @@ class TagRead(TagBase):
class TagPagination(Pagination):
items: list[TagRead]
+
+
+# Tag recommendation models
+class TagRecommendation(DispatchBase):
+ """Model for a single tag recommendation."""
+
+ id: PrimaryKey
+ name: str
+ reason: str
+
+
+class TagTypeRecommendation(DispatchBase):
+ """Model for tag recommendations grouped by tag type."""
+
+ tag_type_id: PrimaryKey
+ tags: list[TagRecommendation]
+
+
+class TagRecommendationResponse(DispatchBase):
+ """Response model for tag recommendations."""
+
+ recommendations: list[TagTypeRecommendation]
+ error_message: str | None = None
diff --git a/src/dispatch/tag/views.py b/src/dispatch/tag/views.py
index 9776bbfc7f14..4de4f4c8f21c 100644
--- a/src/dispatch/tag/views.py
+++ b/src/dispatch/tag/views.py
@@ -1,14 +1,15 @@
from fastapi import APIRouter, HTTPException, status
-from dispatch.database.core import DbSession, get_class_by_tablename
+from dispatch.ai import service as ai_service
+from dispatch.database.core import DbSession
from dispatch.database.service import CommonParameters, search_filter_sort_paginate
from dispatch.models import PrimaryKey
-from dispatch.tag.recommender import get_recommendations
from .models import (
TagCreate,
TagPagination,
TagRead,
+ TagRecommendationResponse,
TagUpdate,
)
from .service import create, delete, get, update
@@ -64,21 +65,21 @@ def delete_tag(db_session: DbSession, tag_id: PrimaryKey):
delete(db_session=db_session, tag_id=tag_id)
-@router.get("/recommendations/{model_name}/{id}", response_model=TagPagination)
-def get_tag_recommendations(db_session: DbSession, model_name: str, id: int):
+@router.get(
+ "/recommendations/{project_id}/case/{case_id}", response_model=TagRecommendationResponse
+)
+def get_tag_recommendations_case(db_session: DbSession, project_id: int, case_id: int):
"""Retrieves a tag recommendation based on the model and model id."""
- model_object = get_class_by_tablename(model_name)
- model = db_session.query(model_object).filter(model_object.id == id).one_or_none()
- project_slug = model.project.slug
- organization_slug = model.project.organization.slug
+ return ai_service.get_tag_recommendations(
+ db_session=db_session, project_id=project_id, case_id=case_id
+ )
- if not model:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=[{"msg": f"No model with id {id} and name {model_name} found."}],
- )
- tags = get_recommendations(
- db_session, [t.id for t in model.tags], organization_slug, project_slug, model_name
+@router.get(
+ "/recommendations/{project_id}/incident/{incident_id}", response_model=TagRecommendationResponse
+)
+def get_tag_recommendations_incident(db_session: DbSession, project_id: int, incident_id: int):
+ """Retrieves a tag recommendation based on the model and model id."""
+ return ai_service.get_tag_recommendations(
+ db_session=db_session, project_id=project_id, incident_id=incident_id
)
- return {"items": tags, "total": len(tags)}
diff --git a/src/dispatch/tag_type/models.py b/src/dispatch/tag_type/models.py
index 0cb6b7c6d580..adddf2dc3543 100644
--- a/src/dispatch/tag_type/models.py
+++ b/src/dispatch/tag_type/models.py
@@ -32,6 +32,7 @@ class TagType(Base, TimeStampMixin, ProjectMixin):
icon = Column(String)
search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple"))
use_for_project_folder = Column(Boolean, default=False, server_default="f")
+ genai_suggestions = Column(Boolean, default=False)
# Pydantic models
@@ -49,6 +50,7 @@ class TagTypeBase(DispatchBase):
color: str | None = None
icon: str | None = None
use_for_project_folder: bool | None = False
+ genai_suggestions: bool | None = False
class TagTypeCreate(TagTypeBase):