Skip to content
This repository was archived by the owner on Sep 3, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/dispatch/ai/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

tactical_report_created = "AI-generated tactical report created for incident {incident_name}"
25 changes: 25 additions & 0 deletions src/dispatch/ai/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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
)
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
96 changes: 95 additions & 1 deletion src/dispatch/ai/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -700,3 +700,97 @@ 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)


def generate_tactical_report(
*,
db_session,
incident: Incident,
project: Project,
important_reaction: str | None = None,
) -> 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.
"""

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 for {incident.name}. No artificial-intelligence plugin enabled."
log.warning(message)
return TacticalReportResponse(error_message=message)

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 for {incident.name}. No conversation plugin enabled."
)
log.warning(message)
return TacticalReportResponse(error_message=message)

conversation = conversation_plugin.instance.get_conversation(
conversation_id=incident.conversation.channel_id, important_reaction=important_reaction
)
if not conversation:
message = f"Tactical report not generated for {incident.name}. No conversation found."
log.warning(message)
return TacticalReportResponse(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. 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. 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 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 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.

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
)

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)

except Exception as e:
error_message = f"Error generating tactical report: {str(e)}"
log.exception(error_message)
return TacticalReportResponse(error_message = error_message)
28 changes: 28 additions & 0 deletions src/dispatch/incident/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -314,6 +315,33 @@ 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
"""
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}")
response = ai_service.generate_tactical_report(
db_session=db_session,
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(
"/{incident_id}/report/executive",
Expand Down
20 changes: 19 additions & 1 deletion src/dispatch/static/dispatch/src/incident/ReportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@
/>
</v-card-text>
</v-card>
<v-row class="mt-4 px-3">
<v-col cols="auto">
<v-btn
color="primary"
@click="generateTacticalReport"
:loading="tactical_report_loading"
>
Draft with GenAI
</v-btn>
</v-col>
<v-col cols="auto" class="d-flex align-center">
<span v-if="tactical_report_loading" class="ml-2"
>AI-generated reports may be unreliable. Be sure to review the output before
saving.</span
>
</v-col>
</v-row>
</v-window-item>
<v-window-item key="executive" value="executive">
<v-card>
Expand Down Expand Up @@ -95,14 +112,15 @@ 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",
]),
},

methods: {
...mapActions("incident", ["closeReportDialog", "createReport"]),
...mapActions("incident", ["closeReportDialog", "createReport", "generateTacticalReport"]),
},
}
</script>
4 changes: 4 additions & 0 deletions src/dispatch/static/dispatch/src/incident/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
56 changes: 56 additions & 0 deletions src/dispatch/static/dispatch/src/incident/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const getDefaultReportState = () => {
actions: null,
needs: null,
},
tactical_report_loading: false,
executive: {
current_status: null,
overview: null,
Expand Down Expand Up @@ -528,6 +529,53 @@ const actions = {
)
})
},
generateTacticalReport: debounce(({ commit, state }) => {
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

// 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 =
"\n\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) + ai_warning,
})
commit(
"notification_backend/addBeNotification",
{
text: "Tactical report generated successfully.",
type: "success",
},
{ root: true }
)
} else {
commit(
"notification_backend/addBeNotification",
{
text:
response.data.error_message != null
? response.data.error_message
: "Unknown error generating tactical report.",
type: "error",
},
{ root: true }
)
}
commit("SET_TACTICAL_REPORT_LOADING", false)
})
}, 500),
createAllResources({ commit, dispatch }) {
commit("SET_SELECTED_LOADING", true)
return IncidentApi.createAllResources(state.selected.id)
Expand Down Expand Up @@ -678,6 +726,14 @@ 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
state.report.tactical.needs = needs
},
RESET_SELECTED(state) {
state.selected = Object.assign(state.selected, getDefaultSelectedState())
state.report = Object.assign(state.report, getDefaultReportState())
Expand Down
Loading
Loading