From 212f31d3393d18f79c6fa1dfe4ea3150d59a6a99 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Tue, 15 Jul 2025 13:12:27 -0400 Subject: [PATCH 01/16] rough retrieval --- src/dispatch/ai/enums.py | 2 + src/dispatch/ai/models.py | 25 +++++ src/dispatch/ai/service.py | 106 +++++++++++++++++- src/dispatch/cli.py | 2 + src/dispatch/incident/views.py | 22 ++++ .../dispatch/src/incident/ReportDialog.vue | 53 ++++++++- .../static/dispatch/src/incident/api.js | 4 + .../static/dispatch/src/incident/store.js | 4 + .../dispatch/src/plugin/NewEditSheet.vue | 2 +- 9 files changed, 216 insertions(+), 4 deletions(-) diff --git a/src/dispatch/ai/enums.py b/src/dispatch/ai/enums.py index baf607d0a47e..acd50ab772ce 100644 --- a/src/dispatch/ai/enums.py +++ b/src/dispatch/ai/enums.py @@ -11,3 +11,5 @@ class AIEventDescription(DispatchEnum): """Description templates for AI-generated events.""" read_in_summary_created = "AI-generated read-in summary created for {participant_email}" + # todo(amats): incident id? + tactical_report_created = "AI-generated tactical report created by {creator_email}" diff --git a/src/dispatch/ai/models.py b/src/dispatch/ai/models.py index d2f31df1abb5..3b131ee910c4 100644 --- a/src/dispatch/ai/models.py +++ b/src/dispatch/ai/models.py @@ -34,3 +34,28 @@ class ReadInSummaryResponse(DispatchBase): summary: ReadInSummary | None = None error_message: str | None = None + + +class TacticalReport(DispatchBase): + """ + Model for structured tactical report output from AI analysis. Enforces the presence of fields + dedicated to the incident's conditions, actions, and needs. + """ + conditions: str = Field( + description="Summary of incident circumstances, with focus on scope and impact", default="" + ) + actions: list[str] = Field( + description="Chronological list of actions and analysis by both the party instigating the incident and the response team", + default_factory=list + ) + needs: str | list[str] = Field( + description="Identified and unresolved action items from the incident, or an indication that the incident is at resolution", default="" + ) + + +class TacticalReportResponse(DispatchBase): + """ + Response model for tactical report generation. Includes the structured summary and any error messages. + """ + tactical_report: TacticalReport | None = None + error_message: str | None = None diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index e1805c0b8c15..aa9b5d5ab614 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -21,7 +21,7 @@ from dispatch.enums import EventType from .exceptions import GenAIException -from .models import ReadInSummary, ReadInSummaryResponse +from .models import ReadInSummary, ReadInSummaryResponse, TacticalReport, TacticalReportResponse from .enums import AIEventSource, AIEventDescription log = logging.getLogger(__name__) @@ -700,3 +700,107 @@ def generate_read_in_summary( log.exception(f"Error generating read-in summary: {e}") error_msg = f"Error generating read-in summary: {str(e)}" return ReadInSummaryResponse(error_message=error_msg) + +# TODO(amats): caching time limit as an abuse prevention mechanism? + + +# TODO(amats): channel retrieval overlap info? +def generate_tactical_report( + *, + db_session, + # subject: Subject, # todo(amats) likely not necessary, incidents only + project: Project, + channel_id: str, + important_reaction: str | None = None, + creator_email: str = "" +) -> TacticalReportResponse: + """ + Generate a tactical report for a given subject. + + Args: + channel_id (str): The channel ID to target when fetching conversation history + important_reaction (str): The emoji reaction denoting important messages + + Returns: + TacticalReportResponse: A structured response containing the tactical report or error message. + """ + + # todo(amats): check_for_cached_generation function? + # otherwise, port over "recent event" logic from david's version? + + # todo(amats): check_necessary_plugin function? + genai_plugin = plugin_service.get_active_instance( + db_session=db_session, plugin_type="artificial-intelligence", project_id=project.id + ) + if not genai_plugin: + message = f"Tactical report not generated. No artificial-intelligence plugin enabled." + log.warning(message) + return ReadInSummaryResponse(error_message=message) + + print(type(genai_plugin.instance)) + + conversation_plugin = plugin_service.get_active_instance( + db_session=db_session, plugin_type="conversation", project_id=project.id + ) + if not conversation_plugin: + message = ( + f"Tactical report not generated. No conversation plugin enabled." + ) + log.warning(message) + return ReadInSummaryResponse(error_message=message) + + conversation = conversation_plugin.instance.get_conversation( + conversation_id=channel_id, important_reaction=important_reaction + ) + if not conversation: + message = f"Read-in summary not generated for {subject.name}. No conversation found." + log.warning(message) + return ReadInSummaryResponse(error_message=message) + + system_message = """ + You are a cybersecurity analyst tasked with creating structured tactical reports. Analyze the + provided channel messages and extract these 3 key types of information: + 1. Conditions: the circumstances surrounding the event, including but not limited to initial identification, event description, + affected parties and systems, the nature of the security flaw or security type, and the observable impact both inside and outside + the organization. + 2. Actions: the actions performed in response to the event, including but not limited to containment/mitigation steps, investigation or log analysis, internal + and external communications or notifications, remediation steps (such as policy or configuration changes), and + vendor or partner engagements. Prioritize impactful, executed actions over plans and include an individual or team if reasonable. + 3. Needs: unfulfilled requests associated with the event's resolution, including but not limited to information to gather, + technical remediation steps, process improvements and preventative actions, or alignment/decision making. Include individuals + or teams as assignees where possible. If the incident is at its resolution with no unresolved needs, this section + can instead be populated with a note to that effect. + + Only include the most relevant events and outcomes. Use paragraphs for the conditions section, and either paragraphs or bullet points for actions and needs. + When using bullet points, use professional language and complete sentences with subjects. + """ + + raw_prompt = f"""Analyze the following channel messages regarding a security event and provide a structured tactical report. + + Channel messages: {conversation} + """ + + prompt = prepare_prompt_for_model( + raw_prompt, genai_plugin.instance.configuration.chat_completion_model + ) + + try: + result = genai_plugin.instance.chat_parse( + prompt=prompt, response_model=TacticalReport, system_message=system_message + ) + + # log the event + # todo(amats): separate logging fn allowing params for both of them? + # todo(amats): can reports r gna be incident only + + # if subject.type == IncidentSubjects.incident: + # log_function = event_service.log_incident_event + # else: # case + # log_function = event_service.log_case_event + + return TacticalReportResponse(tactical_report=result) + + except Exception as e: + error_message = f"Error generating tactical report: {str(e)}" + log.exception(error_message) + return TacticalReportResponse(error_message = error_message) diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 1129f79164e8..0d62ef5807ba 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -1152,6 +1152,8 @@ def run_slack_websocket(organization: str, project: str): ) return + print(instance.dict()) + session.close() click.secho("Slack websocket process started...", fg="blue") diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index db26e4d3c507..b8eeeedc6db0 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -4,6 +4,7 @@ from datetime import date, datetime from typing import Annotated from dateutil.relativedelta import relativedelta +from dispatch.ai.models import TacticalReportResponse from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from starlette.requests import Request @@ -314,6 +315,27 @@ def create_tactical_report( organization_slug=organization, ) +@router.get( + '/{incident_id}/report/tactical/generate', + summary="Auto-generate a tactical report based on Slack conversation contents.", + dependencies=[Depends(PermissionsDependency([IncidentEditPermission]))], +) +def generate_tactical_report( + db_session: DbSession, + current_incident: CurrentIncident, +) -> TacticalReportResponse: + """ + Auto-generate a tactical report. Requires an enabled Artificial Intelligence Plugin + """ + print("hello") + if not current_incident.conversation.channel_id: + return TacticalReportResponse(error_message = f"No channel id found for incident {current_incident.id}") + return ai_service.generate_tactical_report( + db_session=db_session, + channel_id=current_incident.conversation.channel_id, + project=current_incident.project + ) + @router.post( "/{incident_id}/report/executive", diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index 0f60bd3e0a8b..9b7a38e207df 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -38,6 +38,9 @@ /> + + Auto-fill Report + @@ -86,7 +89,7 @@ import { mapActions } from "vuex" export default { name: "IncidentReportDialog", data() { - return {} + return {loading: false} }, computed: { ...mapFields("incident", [ @@ -102,7 +105,53 @@ export default { }, methods: { - ...mapActions("incident", ["closeReportDialog", "createReport"]), + ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), + + async autoFillReport() { + this.loading = true + try { + const response = await this.generateTacticalReport() + console.log("this is the response") + console.log(response) + if (response && response.data && response.data.tactical_report) { + const report = response.data.tactical_report + + // Create a new tactical report object with the generated content + const tacticalReport = { + conditions: report.conditions, + actions: report.actions, + needs: report.needs, + } + + // Update the report data in the parent component + this.$emit("update:report", { + type: "tactical", + tactical: tacticalReport, + }) + + this.$store.commit( + "notification_backend/addBeNotification", + { text: "Tactical report generated successfully.", type: "success" }, + { root: true } + ) + } else if (response && response.data && response.data.error_message) { + this.$store.commit( + "notification_backend/addBeNotification", + { text: response.data.error_message, type: "error" }, + { root: true } + ) + } + } catch (error) { + this.$store.commit( + "notification_backend/addBeNotification", + { text: "Failed to generate tactical report.", type: "error" }, + { root: true } + ) + console.error("Error generating tactical report:", error) + } finally { + this.loading = false + } + }, }, } diff --git a/src/dispatch/static/dispatch/src/incident/api.js b/src/dispatch/static/dispatch/src/incident/api.js index 9d1d31089c15..ca5900ef3c68 100644 --- a/src/dispatch/static/dispatch/src/incident/api.js +++ b/src/dispatch/static/dispatch/src/incident/api.js @@ -60,6 +60,10 @@ export default { return API.post(`/${resource}/${incidentId}/report/${type}`, payload) }, + generateTacticalReport(incidentId) { + return API.get(`/${resource}/${incidentId}/report/tactical/generate`) + }, + createNewEvent(incidentId, payload) { return API.post(`/${resource}/${incidentId}/event`, payload) }, diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 8b48cd65e8d2..e9cf5da45151 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -528,6 +528,10 @@ const actions = { ) }) }, + generateTacticalReport({ commit, dispatch}) { + const id = state.selected.id + return IncidentApi.generateTacticalReport(id) + }, createAllResources({ commit, dispatch }) { commit("SET_SELECTED_LOADING", true) return IncidentApi.createAllResources(state.selected.id) diff --git a/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue b/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue index 3432f6804341..f85af77500b0 100644 --- a/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue @@ -92,7 +92,7 @@ export default { }, methods: { - ...mapActions("plugin", ["save", "closeCreateEdit"]), + ...mapActions("plugin", ["save"]), ...mapMutations("plugin", ["addConfigurationItem", "removeConfigurationItem"]), }, From 50c3f748f096ab2a8eaac067b8664c46fd5a6971 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Tue, 15 Jul 2025 14:03:43 -0400 Subject: [PATCH 02/16] populating update --- .../dispatch/src/incident/ReportDialog.vue | 48 +++++-------------- .../static/dispatch/src/incident/store.js | 24 ++++++++-- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index 9b7a38e207df..2c5015c47d05 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -38,9 +38,16 @@ /> - - Auto-fill Report - + + + + Draft with GenAI + + + + AI-generated reports may be unreliable. Be sure to review the output before saving. + + @@ -89,7 +96,7 @@ import { mapActions } from "vuex" export default { name: "IncidentReportDialog", data() { - return {loading: false} + return { loading: false } }, computed: { ...mapFields("incident", [ @@ -107,40 +114,11 @@ export default { methods: { ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), + // TODO(amats): not familiar w every functionality here async autoFillReport() { this.loading = true try { - const response = await this.generateTacticalReport() - console.log("this is the response") - console.log(response) - if (response && response.data && response.data.tactical_report) { - const report = response.data.tactical_report - - // Create a new tactical report object with the generated content - const tacticalReport = { - conditions: report.conditions, - actions: report.actions, - needs: report.needs, - } - - // Update the report data in the parent component - this.$emit("update:report", { - type: "tactical", - tactical: tacticalReport, - }) - - this.$store.commit( - "notification_backend/addBeNotification", - { text: "Tactical report generated successfully.", type: "success" }, - { root: true } - ) - } else if (response && response.data && response.data.error_message) { - this.$store.commit( - "notification_backend/addBeNotification", - { text: response.data.error_message, type: "error" }, - { root: true } - ) - } + await this.generateTacticalReport() } catch (error) { this.$store.commit( "notification_backend/addBeNotification", diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index e9cf5da45151..bf3360dde81e 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -1,5 +1,5 @@ import { getField, updateField } from "vuex-map-fields" -import { debounce } from "lodash" +import { constant, debounce } from "lodash" import SearchUtils from "@/search/utils" import IncidentApi from "@/incident/api" @@ -528,9 +528,22 @@ const actions = { ) }) }, - generateTacticalReport({ commit, dispatch}) { + generateTacticalReport({ commit, dispatch }) { + // commit("SET_SELECTED", state.selected.id) const id = state.selected.id - return IncidentApi.generateTacticalReport(id) + // set a loading state + return IncidentApi.generateTacticalReport(id).then((response) => { + console.log("it me") + console.log(response) + if (response && response.data && response.data.tactical_report) { + const report = response.data.tactical_report + commit("SET_TACTICAL_REPORT", { + conditions: report.conditions, + actions: report.actions, + needs: report.needs, + }) + } + }) }, createAllResources({ commit, dispatch }) { commit("SET_SELECTED_LOADING", true) @@ -682,6 +695,11 @@ const mutations = { SET_DIALOG_REPORT(state, value) { state.dialogs.showReportDialog = value }, + SET_TACTICAL_REPORT(state, { conditions, actions, needs }) { + state.report.tactical.conditions = conditions + state.report.tactical.actions = actions + state.report.tactical.needs = needs + }, RESET_SELECTED(state) { state.selected = Object.assign(state.selected, getDefaultSelectedState()) state.report = Object.assign(state.report, getDefaultReportState()) From c53751b93325674144af46e22470bdf807541a43 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Tue, 15 Jul 2025 14:11:46 -0400 Subject: [PATCH 03/16] confirm working formatting --- .../static/dispatch/src/incident/ReportDialog.vue | 2 +- src/dispatch/static/dispatch/src/incident/store.js | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index 2c5015c47d05..6fce4cb044aa 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -114,7 +114,7 @@ export default { methods: { ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), - // TODO(amats): not familiar w every functionality here + // TODO(amats): not familiar w every functionality here, move to store if possible async autoFillReport() { this.loading = true try { diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index bf3360dde81e..e276a31e75f3 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -537,10 +537,15 @@ const actions = { console.log(response) if (response && response.data && response.data.tactical_report) { const report = response.data.tactical_report + + function formatBullets(list) { + return list.map((item) => `• ${item}`).join("\n") + } + commit("SET_TACTICAL_REPORT", { conditions: report.conditions, - actions: report.actions, - needs: report.needs, + actions: formatBullets(report.actions), + needs: formatBullets(report.needs), }) } }) From 1936201362725c17815b95c1064cf1c7566ec5a3 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Tue, 15 Jul 2025 17:06:03 -0400 Subject: [PATCH 04/16] add error message propagation --- src/dispatch/ai/enums.py | 4 +- src/dispatch/ai/models.py | 2 +- src/dispatch/ai/service.py | 40 +++++++++---------- src/dispatch/incident/views.py | 14 +++++-- .../dispatch/src/incident/ReportDialog.vue | 5 ++- .../static/dispatch/src/incident/store.js | 21 +++++++--- .../dispatch/src/plugin/NewEditSheet.vue | 2 +- 7 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/dispatch/ai/enums.py b/src/dispatch/ai/enums.py index acd50ab772ce..46a187bb4aff 100644 --- a/src/dispatch/ai/enums.py +++ b/src/dispatch/ai/enums.py @@ -11,5 +11,5 @@ class AIEventDescription(DispatchEnum): """Description templates for AI-generated events.""" read_in_summary_created = "AI-generated read-in summary created for {participant_email}" - # todo(amats): incident id? - tactical_report_created = "AI-generated tactical report created by {creator_email}" + + tactical_report_created = "AI-generated tactical report created for incident {incident_name}" diff --git a/src/dispatch/ai/models.py b/src/dispatch/ai/models.py index 3b131ee910c4..00fab91b648d 100644 --- a/src/dispatch/ai/models.py +++ b/src/dispatch/ai/models.py @@ -44,7 +44,7 @@ class TacticalReport(DispatchBase): conditions: str = Field( description="Summary of incident circumstances, with focus on scope and impact", default="" ) - actions: list[str] = Field( + actions: str | list[str] = Field( description="Chronological list of actions and analysis by both the party instigating the incident and the response team", default_factory=list ) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index aa9b5d5ab614..045f45fe4b10 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -701,18 +701,13 @@ def generate_read_in_summary( error_msg = f"Error generating read-in summary: {str(e)}" return ReadInSummaryResponse(error_message=error_msg) -# TODO(amats): caching time limit as an abuse prevention mechanism? - -# TODO(amats): channel retrieval overlap info? def generate_tactical_report( *, db_session, - # subject: Subject, # todo(amats) likely not necessary, incidents only + incident: Incident, project: Project, - channel_id: str, important_reaction: str | None = None, - creator_email: str = "" ) -> TacticalReportResponse: """ Generate a tactical report for a given subject. @@ -750,29 +745,28 @@ def generate_tactical_report( return ReadInSummaryResponse(error_message=message) conversation = conversation_plugin.instance.get_conversation( - conversation_id=channel_id, important_reaction=important_reaction + conversation_id=incident.conversation.channel_id, important_reaction=important_reaction ) if not conversation: - message = f"Read-in summary not generated for {subject.name}. No conversation found." + message = f"Tactical report not generated for {incident.name}. No conversation found." log.warning(message) return ReadInSummaryResponse(error_message=message) system_message = """ You are a cybersecurity analyst tasked with creating structured tactical reports. Analyze the provided channel messages and extract these 3 key types of information: - 1. Conditions: the circumstances surrounding the event, including but not limited to initial identification, event description, + 1. Conditions: the circumstances surrounding the event. For example, initial identification, event description, affected parties and systems, the nature of the security flaw or security type, and the observable impact both inside and outside the organization. - 2. Actions: the actions performed in response to the event, including but not limited to containment/mitigation steps, investigation or log analysis, internal + 2. Actions: the actions performed in response to the event. For example, containment/mitigation steps, investigation or log analysis, internal and external communications or notifications, remediation steps (such as policy or configuration changes), and - vendor or partner engagements. Prioritize impactful, executed actions over plans and include an individual or team if reasonable. - 3. Needs: unfulfilled requests associated with the event's resolution, including but not limited to information to gather, + vendor or partner engagements. Prioritize executed actions over plans. Include relevant team or individual names. + 3. Needs: unfulfilled requests associated with the event's resolution. For example, information to gather, technical remediation steps, process improvements and preventative actions, or alignment/decision making. Include individuals or teams as assignees where possible. If the incident is at its resolution with no unresolved needs, this section can instead be populated with a note to that effect. - Only include the most relevant events and outcomes. Use paragraphs for the conditions section, and either paragraphs or bullet points for actions and needs. - When using bullet points, use professional language and complete sentences with subjects. + Only include the most impactful events and outcomes. Be clear, professional, and concise. Use complete sentences with clear subjects, including when writing in bullet points. """ raw_prompt = f"""Analyze the following channel messages regarding a security event and provide a structured tactical report. @@ -789,14 +783,16 @@ def generate_tactical_report( prompt=prompt, response_model=TacticalReport, system_message=system_message ) - # log the event - # todo(amats): separate logging fn allowing params for both of them? - # todo(amats): can reports r gna be incident only - - # if subject.type == IncidentSubjects.incident: - # log_function = event_service.log_incident_event - # else: # case - # log_function = event_service.log_case_event + event_service.log_incident_event( + db_session=db_session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.tactical_report_created.format( + incident_name=incident.name + ), + incident_id=incident.id, + details=result.dict(), + type=EventType.other + ) return TacticalReportResponse(tactical_report=result) diff --git a/src/dispatch/incident/views.py b/src/dispatch/incident/views.py index b8eeeedc6db0..c613c7e2a172 100644 --- a/src/dispatch/incident/views.py +++ b/src/dispatch/incident/views.py @@ -327,14 +327,20 @@ def generate_tactical_report( """ Auto-generate a tactical report. Requires an enabled Artificial Intelligence Plugin """ - print("hello") - if not current_incident.conversation.channel_id: + if not current_incident.conversation or not current_incident.conversation.channel_id: return TacticalReportResponse(error_message = f"No channel id found for incident {current_incident.id}") - return ai_service.generate_tactical_report( + response = ai_service.generate_tactical_report( db_session=db_session, - channel_id=current_incident.conversation.channel_id, + incident=current_incident, project=current_incident.project ) + if not response.tactical_report: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=[{"msg": (response.error_message if response.error_message else "Unknown error generating tactical report.")}], + ) + return response + @router.post( diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index 6fce4cb044aa..c1ba4301546c 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -45,7 +45,10 @@ - AI-generated reports may be unreliable. Be sure to review the output before saving. + AI-generated reports may be unreliable. Be sure to review the output before + saving. diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index e276a31e75f3..59a10e3ef6b7 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -59,6 +59,7 @@ const getDefaultReportState = () => { actions: null, needs: null, }, + tactical_generation_error: null, executive: { current_status: null, overview: null, @@ -528,16 +529,13 @@ const actions = { ) }) }, - generateTacticalReport({ commit, dispatch }) { - // commit("SET_SELECTED", state.selected.id) + generateTacticalReport({ commit }) { const id = state.selected.id - // set a loading state return IncidentApi.generateTacticalReport(id).then((response) => { - console.log("it me") - console.log(response) if (response && response.data && response.data.tactical_report) { const report = response.data.tactical_report + // eslint-disable-next-line no-inner-declarations function formatBullets(list) { return list.map((item) => `• ${item}`).join("\n") } @@ -548,6 +546,19 @@ const actions = { needs: formatBullets(report.needs), }) } + else { + commit( + "notification_backend/addBeNotification", + { + text: + response.data.error_message != null + ? response.data.error_message + : "Unknown error generating tactical report.", + type: "error", + }, + { root: true } + ) + } }) }, createAllResources({ commit, dispatch }) { diff --git a/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue b/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue index f85af77500b0..3432f6804341 100644 --- a/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/plugin/NewEditSheet.vue @@ -92,7 +92,7 @@ export default { }, methods: { - ...mapActions("plugin", ["save"]), + ...mapActions("plugin", ["save", "closeCreateEdit"]), ...mapMutations("plugin", ["addConfigurationItem", "removeConfigurationItem"]), }, From 06bd35311a512b3ccca2835f2b188e6ce8859380 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:03:43 -0400 Subject: [PATCH 05/16] clean up report dialog --- src/dispatch/ai/service.py | 6 ------ src/dispatch/cli.py | 2 -- .../static/dispatch/src/incident/ReportDialog.vue | 15 ++------------- .../static/dispatch/src/incident/store.js | 13 ++++++++++--- 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 045f45fe4b10..3e864497d8c6 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -720,10 +720,6 @@ def generate_tactical_report( TacticalReportResponse: A structured response containing the tactical report or error message. """ - # todo(amats): check_for_cached_generation function? - # otherwise, port over "recent event" logic from david's version? - - # todo(amats): check_necessary_plugin function? genai_plugin = plugin_service.get_active_instance( db_session=db_session, plugin_type="artificial-intelligence", project_id=project.id ) @@ -732,8 +728,6 @@ def generate_tactical_report( log.warning(message) return ReadInSummaryResponse(error_message=message) - print(type(genai_plugin.instance)) - conversation_plugin = plugin_service.get_active_instance( db_session=db_session, plugin_type="conversation", project_id=project.id ) diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 0d62ef5807ba..1129f79164e8 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -1152,8 +1152,6 @@ def run_slack_websocket(organization: str, project: str): ) return - print(instance.dict()) - session.close() click.secho("Slack websocket process started...", fg="blue") diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index c1ba4301546c..459be8714294 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -117,21 +117,10 @@ export default { methods: { ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), - // TODO(amats): not familiar w every functionality here, move to store if possible async autoFillReport() { this.loading = true - try { - await this.generateTacticalReport() - } catch (error) { - this.$store.commit( - "notification_backend/addBeNotification", - { text: "Failed to generate tactical report.", type: "error" }, - { root: true } - ) - console.error("Error generating tactical report:", error) - } finally { - this.loading = false - } + await this.generateTacticalReport() + this.loading = false }, }, } diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 59a10e3ef6b7..ccca173567e7 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -1,5 +1,5 @@ import { getField, updateField } from "vuex-map-fields" -import { constant, debounce } from "lodash" +import { debounce } from "lodash" import SearchUtils from "@/search/utils" import IncidentApi from "@/incident/api" @@ -545,8 +545,15 @@ const actions = { actions: formatBullets(report.actions), needs: formatBullets(report.needs), }) - } - else { + commit( + "notification_backend/addBeNotification", + { + text: "Tactical report generated successfully.", + type: "success", + }, + { root: true } + ) + } else { commit( "notification_backend/addBeNotification", { From 256a03bc69bae129fdede9d668364df10512b091 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:04:48 -0400 Subject: [PATCH 06/16] remove unused variable --- src/dispatch/static/dispatch/src/incident/store.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index ccca173567e7..f5963f2a8cf6 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -59,7 +59,6 @@ const getDefaultReportState = () => { actions: null, needs: null, }, - tactical_generation_error: null, executive: { current_status: null, overview: null, From ed3cb0749527717e4c211ea0f8637432a438d8b0 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:18:35 -0400 Subject: [PATCH 07/16] add debounce --- .../static/dispatch/src/incident/ReportDialog.vue | 7 +++---- .../static/dispatch/src/incident/store.js | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index 459be8714294..eae7073a1488 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -40,12 +40,12 @@ - + Draft with GenAI - AI-generated reports may be unreliable. Be sure to review the output before saving. @@ -108,6 +108,7 @@ export default { "report.tactical.conditions", "report.tactical.actions", "report.tactical.needs", + "report.tactical_report_loading", "report.executive.current_status", "report.executive.overview", "report.executive.next_steps", @@ -118,9 +119,7 @@ export default { ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), async autoFillReport() { - this.loading = true await this.generateTacticalReport() - this.loading = false }, }, } diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index f5963f2a8cf6..3eac370b0217 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -59,6 +59,7 @@ const getDefaultReportState = () => { actions: null, needs: null, }, + tactical_report_loading: false, executive: { current_status: null, overview: null, @@ -528,8 +529,9 @@ const actions = { ) }) }, - generateTacticalReport({ commit }) { + generateTacticalReport: debounce(({ commit }) => { const id = state.selected.id + commit("SET_TACTICAL_REPORT_LOADING", true) return IncidentApi.generateTacticalReport(id).then((response) => { if (response && response.data && response.data.tactical_report) { const report = response.data.tactical_report @@ -539,10 +541,13 @@ const actions = { return list.map((item) => `• ${item}`).join("\n") } + let ai_warning = + "\nThis report was generated by AI. Please verify all information before relying on it. " + commit("SET_TACTICAL_REPORT", { conditions: report.conditions, actions: formatBullets(report.actions), - needs: formatBullets(report.needs), + needs: formatBullets(report.needs) + ai_warning, }) commit( "notification_backend/addBeNotification", @@ -565,8 +570,9 @@ const actions = { { root: true } ) } + commit("SET_TACTICAL_REPORT_LOADING", false) }) - }, + }, 500), createAllResources({ commit, dispatch }) { commit("SET_SELECTED_LOADING", true) return IncidentApi.createAllResources(state.selected.id) @@ -717,6 +723,9 @@ const mutations = { SET_DIALOG_REPORT(state, value) { state.dialogs.showReportDialog = value }, + SET_TACTICAL_REPORT_LOADING(state, value) { + state.report.tactical_report_loading = value + }, SET_TACTICAL_REPORT(state, { conditions, actions, needs }) { state.report.tactical.conditions = conditions state.report.tactical.actions = actions From 23d5b5808066cec434c91de153108f024762dbe6 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:22:44 -0400 Subject: [PATCH 08/16] test suite --- src/dispatch/ai/service.py | 6 +- tests/ai/test_ai_service.py | 220 +++++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 5 deletions(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 3e864497d8c6..811d1b3a0d40 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -726,7 +726,7 @@ def generate_tactical_report( if not genai_plugin: message = f"Tactical report not generated. No artificial-intelligence plugin enabled." log.warning(message) - return ReadInSummaryResponse(error_message=message) + return TacticalReportResponse(error_message=message) conversation_plugin = plugin_service.get_active_instance( db_session=db_session, plugin_type="conversation", project_id=project.id @@ -736,7 +736,7 @@ def generate_tactical_report( f"Tactical report not generated. No conversation plugin enabled." ) log.warning(message) - return ReadInSummaryResponse(error_message=message) + return TacticalReportResponse(error_message=message) conversation = conversation_plugin.instance.get_conversation( conversation_id=incident.conversation.channel_id, important_reaction=important_reaction @@ -744,7 +744,7 @@ def generate_tactical_report( if not conversation: message = f"Tactical report not generated for {incident.name}. No conversation found." log.warning(message) - return ReadInSummaryResponse(error_message=message) + return TacticalReportResponse(error_message=message) system_message = """ You are a cybersecurity analyst tasked with creating structured tactical reports. Analyze the diff --git a/tests/ai/test_ai_service.py b/tests/ai/test_ai_service.py index 4e9d996702c2..eea7e9186317 100644 --- a/tests/ai/test_ai_service.py +++ b/tests/ai/test_ai_service.py @@ -1,8 +1,8 @@ import pytest from unittest.mock import Mock, patch -from dispatch.ai.service import generate_read_in_summary, READ_IN_SUMMARY_CACHE_DURATION -from dispatch.ai.models import ReadInSummary, ReadInSummaryResponse +from dispatch.ai.service import generate_read_in_summary, READ_IN_SUMMARY_CACHE_DURATION, generate_tactical_report +from dispatch.ai.models import ReadInSummary, ReadInSummaryResponse, TacticalReport, TacticalReportResponse from dispatch.ai.enums import AIEventSource, AIEventDescription from dispatch.plugins.dispatch_slack.models import IncidentSubjects, CaseSubjects from dispatch.enums import EventType @@ -472,3 +472,219 @@ def test_generate_read_in_summary_event_query_case(self, session, mock_subject, mock_get_event.assert_called_once_with( session, case_id=mock_subject.id, max_age_seconds=READ_IN_SUMMARY_CACHE_DURATION ) + + + +class TestGenerateTacticalReport: + """Test suite for generate_tactical_report function.""" + + @pytest.fixture + def mock_incident(self): + incident = Mock() + incident.id = 321 + incident.name = "Tactical Incident" + incident.conversation = Mock() + incident.conversation.channel_id = "tactical-channel" + return incident + + @pytest.fixture + def mock_project(self): + project = Mock() + project.id = 654 + return project + + @pytest.fixture + def mock_conversation(self): + return [ + {"user": "user1", "text": "Initial event", "timestamp": "2024-02-01 10:00"}, + {"user": "user2", "text": "Mitigation step", "timestamp": "2024-02-01 10:15"}, + ] + + @pytest.fixture + def mock_tactical_report(self): + return TacticalReport( + conditions="These are the conditions", + actions=["Action 1", "Action 2"], + needs=["Need 1"] + ) + + def test_generate_tactical_report_success( + self, session, mock_incident, mock_project, mock_conversation, mock_tactical_report + ): + """Test successful tactical report generation.""" + with ( + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.event_service.log_incident_event") as mock_log_event, + ): + # Mock AI plugin + mock_ai_plugin = Mock() + mock_ai_plugin.instance.configuration.chat_completion_model = "gpt-4" + mock_ai_plugin.instance.chat_parse.return_value = mock_tactical_report + + # Mock conversation plugin + mock_conv_plugin = Mock() + mock_conv_plugin.instance.get_conversation.return_value = mock_conversation + + # Configure plugin service to return our mocks + def get_plugin_side_effect(db_session, plugin_type, project_id): + if plugin_type == "artificial-intelligence": + return mock_ai_plugin + elif plugin_type == "conversation": + return mock_conv_plugin + return None + + mock_get_plugin.side_effect = get_plugin_side_effect + + # Call the function + result = generate_tactical_report( + db_session=session, + incident=mock_incident, + project=mock_project, + important_reaction=":fire:", + ) + + # Assertions + assert isinstance(result, TacticalReportResponse) + assert result.tactical_report is not None + assert result.error_message is None + assert result.tactical_report.conditions == mock_tactical_report.conditions + assert result.tactical_report.actions == mock_tactical_report.actions + assert result.tactical_report.needs == mock_tactical_report.needs + + # Verify event logging + mock_log_event.assert_called_once_with( + db_session=session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.tactical_report_created.format( + incident_name=mock_incident.name + ), + incident_id=mock_incident.id, + details=mock_tactical_report.dict(), + type=EventType.other, + ) + + def test_generate_tactical_report_no_ai_plugin(self, session, mock_incident, mock_project): + """Test tactical report generation when no AI plugin is available.""" + with ( + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + # No AI plugin + mock_get_plugin.side_effect = lambda db_session, plugin_type, project_id: ( + None if plugin_type == "artificial-intelligence" else Mock() + ) + + result = generate_tactical_report( + db_session=session, + incident=mock_incident, + project=mock_project, + important_reaction=":fire:", + ) + print(type(result)) + assert isinstance(result, TacticalReportResponse) + assert result.tactical_report is None + assert result.error_message is not None + assert "No artificial-intelligence plugin enabled" in result.error_message + mock_log.warning.assert_called() + + def test_generate_tactical_report_no_conversation_plugin( + self, session, mock_incident, mock_project + ): + """Test tactical report generation when no conversation plugin is available.""" + with ( + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + # AI plugin present, no conversation plugin + mock_ai_plugin = Mock() + mock_get_plugin.side_effect = lambda db_session, plugin_type, project_id: ( + mock_ai_plugin if plugin_type == "artificial-intelligence" else None + ) + + result = generate_tactical_report( + db_session=session, + incident=mock_incident, + project=mock_project, + important_reaction=":fire:", + ) + + assert isinstance(result, TacticalReportResponse) + assert result.tactical_report is None + assert result.error_message is not None + assert "No conversation plugin enabled" in result.error_message + mock_log.warning.assert_called() + + def test_generate_tactical_report_no_conversation( + self, session, mock_incident, mock_project + ): + """Test tactical report generation when no conversation is found.""" + with ( + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + # Mock AI plugin + mock_ai_plugin = Mock() + # Mock conversation plugin that returns no conversation + mock_conv_plugin = Mock() + mock_conv_plugin.instance.get_conversation.return_value = None + + def get_plugin_side_effect(db_session, plugin_type, project_id): + if plugin_type == "artificial-intelligence": + return mock_ai_plugin + elif plugin_type == "conversation": + return mock_conv_plugin + return None + + mock_get_plugin.side_effect = get_plugin_side_effect + + result = generate_tactical_report( + db_session=session, + incident=mock_incident, + project=mock_project, + important_reaction=":fire:", + ) + + assert isinstance(result, TacticalReportResponse) + assert result.tactical_report is None + assert result.error_message is not None + assert "No conversation found" in result.error_message + mock_log.warning.assert_called() + + def test_generate_tactical_report_ai_error( + self, session, mock_incident, mock_project, mock_conversation + ): + """Test tactical report generation when AI plugin throws an error.""" + with ( + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + # Mock AI plugin with error + mock_ai_plugin = Mock() + mock_ai_plugin.instance.configuration.chat_completion_model = "gpt-4" + mock_ai_plugin.instance.chat_parse.side_effect = Exception("AI error") + + # Mock conversation plugin + mock_conv_plugin = Mock() + mock_conv_plugin.instance.get_conversation.return_value = mock_conversation + + def get_plugin_side_effect(db_session, plugin_type, project_id): + if plugin_type == "artificial-intelligence": + return mock_ai_plugin + elif plugin_type == "conversation": + return mock_conv_plugin + return None + + mock_get_plugin.side_effect = get_plugin_side_effect + + result = generate_tactical_report( + db_session=session, + incident=mock_incident, + project=mock_project, + important_reaction=":fire:", + ) + + assert isinstance(result, TacticalReportResponse) + assert result.tactical_report is None + assert result.error_message is not None + assert "Error generating tactical report" in result.error_message + mock_log.exception.assert_called() From 5dcbc94af326bc47d07ff7ac349a4b5a2b166255 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:32:56 -0400 Subject: [PATCH 09/16] format --- .../static/dispatch/src/incident/ReportDialog.vue | 10 +++++----- src/dispatch/static/dispatch/src/incident/store.js | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index eae7073a1488..e5295c57f629 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -40,7 +40,11 @@ - + Draft with GenAI @@ -117,10 +121,6 @@ export default { methods: { ...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]), - - async autoFillReport() { - await this.generateTacticalReport() - }, }, } diff --git a/src/dispatch/static/dispatch/src/incident/store.js b/src/dispatch/static/dispatch/src/incident/store.js index 3eac370b0217..090d3fde82f0 100644 --- a/src/dispatch/static/dispatch/src/incident/store.js +++ b/src/dispatch/static/dispatch/src/incident/store.js @@ -538,11 +538,14 @@ const actions = { // eslint-disable-next-line no-inner-declarations function formatBullets(list) { + if (!Array.isArray(list)) { + return list + } return list.map((item) => `• ${item}`).join("\n") } let ai_warning = - "\nThis report was generated by AI. Please verify all information before relying on it. " + "\n\nThis report was generated by AI. Please verify all information before relying on it. " commit("SET_TACTICAL_REPORT", { conditions: report.conditions, From 0bb17a704173bd3b01447bb43cb118d5700180f4 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:47:47 -0400 Subject: [PATCH 10/16] lint --- src/dispatch/ai/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 811d1b3a0d40..44efffc19aeb 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -733,7 +733,7 @@ def generate_tactical_report( ) if not conversation_plugin: message = ( - f"Tactical report not generated. No conversation plugin enabled." + f"Tactical report not generated for {incident.name}. No conversation plugin enabled." ) log.warning(message) return TacticalReportResponse(error_message=message) From 4224fdf5948ee0b76d05ab2e1daf0f68fb9eefa6 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 10:51:22 -0400 Subject: [PATCH 11/16] lint 2 --- src/dispatch/ai/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 44efffc19aeb..8ca232c26498 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -724,7 +724,7 @@ def generate_tactical_report( db_session=db_session, plugin_type="artificial-intelligence", project_id=project.id ) if not genai_plugin: - message = f"Tactical report not generated. No artificial-intelligence plugin enabled." + message = f"Tactical report not generated for {incident.name}. No artificial-intelligence plugin enabled." log.warning(message) return TacticalReportResponse(error_message=message) From 0ba9e5c92d54d6ec1a065c005920087d592f940d Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 13:45:52 -0400 Subject: [PATCH 12/16] resolve user details in slack conversation history --- src/dispatch/ai/service.py | 4 +-- src/dispatch/plugins/dispatch_slack/plugin.py | 5 +++- .../plugins/dispatch_slack/service.py | 25 ++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 8ca232c26498..25622b706db1 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -636,7 +636,7 @@ def generate_read_in_summary( return ReadInSummaryResponse(error_message=message) conversation = conversation_plugin.instance.get_conversation( - conversation_id=channel_id, important_reaction=important_reaction + conversation_id=channel_id, include_user_details=True, important_reaction=important_reaction ) if not conversation: message = f"Read-in summary not generated for {subject.name}. No conversation found." @@ -739,7 +739,7 @@ def generate_tactical_report( return TacticalReportResponse(error_message=message) conversation = conversation_plugin.instance.get_conversation( - conversation_id=incident.conversation.channel_id, important_reaction=important_reaction + conversation_id=incident.conversation.channel_id, include_user_details=True, important_reaction=important_reaction ) if not conversation: message = f"Tactical report not generated for {incident.name}. No conversation found." diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 906fb5b6bf09..8b47fe63d15d 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -450,7 +450,7 @@ def fetch_events( raise def get_conversation( - self, conversation_id: str, oldest: str = "0", important_reaction: str | None = None + self, conversation_id: str, oldest: str = "0", include_user_details = False, important_reaction: str | None = None ) -> list: """ Fetches the top-level posts from a Slack conversation. @@ -458,6 +458,8 @@ def get_conversation( Args: conversation_id (str): The ID of the Slack conversation. oldest (str): The oldest timestamp to fetch messages from. + include_user_details (bool): Whether to resolve user name and email information. + important_reaction (str): Emoji reaction indicating important messages. Returns: list: A list of tuples containing the timestamp and user ID of each message. @@ -468,6 +470,7 @@ def get_conversation( conversation_id, oldest, include_message_text=True, + include_user_details=include_user_details, important_reaction=important_reaction, ) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 41fb529ee104..134a4055fb45 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -610,10 +610,19 @@ def get_channel_activity( conversation_id: str, oldest: str = "0", include_message_text: bool = False, + include_user_details: bool = False, important_reaction: str | None = None, ) -> list: """Gets all top-level messages for a given Slack channel. + Args: + client (WebClient): Slack client responsible for API calls + conversation_id (str): Channel ID to reference + oldest (int): oldest message to retrieve + include_message_text (bool): Include message text (in addition to datetime and user id) + include_user_details (bool): Include user name and email information + important_reaction (str): Optional emoji reaction designating important messages + Returns: A sorted list of tuples (utc_dt, user_id) of each message in the channel, or (utc_dt, user_id, message_text), depending on include_message_text. @@ -640,13 +649,23 @@ def get_channel_activity( if "user" in message: user_id = resolve_user(client, message["user"])["id"] utc_dt = datetime.utcfromtimestamp(float(message["ts"])) + + message_result = [utc_dt, user_id] + if include_message_text: message_text = message.get("text", "") if has_important_reaction(message, important_reaction): message_text = f"IMPORTANT!: {message_text}" - heapq.heappush(result, (utc_dt, user_id, message_text)) - else: - heapq.heappush(result, (utc_dt, user_id)) + message_result.append(message_text) + + if include_user_details: + user_details = get_user_info_by_id(client, user_id) + user_name = user_details.get('real_name', "Name not found") + user_display_name = user_details.get('display_name_normalized', "DisplayName not found") + user_email = user_details.get('email', "Email not found") + message_result.extend([user_name, user_display_name, user_email]) + + heapq.heappush(result, tuple(message_result)) if not response["has_more"]: break From 3931e03f472e32efa63a954a92646f18be2d362d Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 13:47:24 -0400 Subject: [PATCH 13/16] fix docstring --- src/dispatch/plugins/dispatch_slack/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 134a4055fb45..067653587893 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -618,7 +618,7 @@ def get_channel_activity( Args: client (WebClient): Slack client responsible for API calls conversation_id (str): Channel ID to reference - oldest (int): oldest message to retrieve + oldest (int): Oldest timestamp to fetch messages from include_message_text (bool): Include message text (in addition to datetime and user id) include_user_details (bool): Include user name and email information important_reaction (str): Optional emoji reaction designating important messages From bf1f260f6d7303c8842a754a1928bd31275726fe Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Wed, 16 Jul 2025 13:57:35 -0400 Subject: [PATCH 14/16] update profile resolution --- src/dispatch/plugins/dispatch_slack/service.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 067653587893..07a7975d3168 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -661,8 +661,9 @@ def get_channel_activity( if include_user_details: user_details = get_user_info_by_id(client, user_id) user_name = user_details.get('real_name', "Name not found") - user_display_name = user_details.get('display_name_normalized', "DisplayName not found") - user_email = user_details.get('email', "Email not found") + user_profile = user_details.get('profile', {}) + user_display_name = user_profile.get('display_name_normalized', "DisplayName not found") + user_email = user_profile.get('email', "Email not found") message_result.extend([user_name, user_display_name, user_email]) heapq.heappush(result, tuple(message_result)) From 85ef6f1dfaa62808bba0c0eaa2e834715437cf68 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto <56315176+aliciamatsumoto@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:05:05 -0400 Subject: [PATCH 15/16] Update ReportDialog.vue Signed-off-by: Alicia Matsumoto <56315176+aliciamatsumoto@users.noreply.github.com> --- src/dispatch/static/dispatch/src/incident/ReportDialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue index e5295c57f629..0d11ec416081 100644 --- a/src/dispatch/static/dispatch/src/incident/ReportDialog.vue +++ b/src/dispatch/static/dispatch/src/incident/ReportDialog.vue @@ -103,7 +103,7 @@ import { mapActions } from "vuex" export default { name: "IncidentReportDialog", data() { - return { loading: false } + return {} }, computed: { ...mapFields("incident", [ From 7a24ceda0f8413415dea35722c8e373f9fb921b2 Mon Sep 17 00:00:00 2001 From: Alicia Matsumoto Date: Thu, 17 Jul 2025 10:20:34 -0400 Subject: [PATCH 16/16] resolve mentioned users --- .../plugins/dispatch_slack/service.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index 07a7975d3168..b028d92fbfd7 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -1,6 +1,7 @@ import functools import heapq import logging +import re from datetime import datetime from blockkit.surfaces import Block @@ -629,6 +630,20 @@ def get_channel_activity( """ result = [] cursor = None + + def mention_resolver(user_match): + """ + Helper function to extract user informations from @ mentions in messages. + """ + user_id = user_match.group(1) + try: + user_info = get_user_info_by_id(client, user_id) + return user_info.get('real_name', f"{user_id} (name not found)") + except SlackApiError as e: + log.warning(f"Error resolving mentioned Slack user: {e}") + # fall back on id + return user_id + while True: response = make_call( client, @@ -656,6 +671,10 @@ def get_channel_activity( message_text = message.get("text", "") if has_important_reaction(message, important_reaction): message_text = f"IMPORTANT!: {message_text}" + + if include_user_details: # attempt to resolve mentioned users + message_text = re.sub(r'<@(\w+)>', mention_resolver, message_text) + message_result.append(message_text) if include_user_details: