From ba19eccc68f59cc5f40bdf270f4afc3dcaa07ba6 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Wed, 9 Jul 2025 10:41:08 -0700 Subject: [PATCH 1/6] feat(ai): allow for read-in summaries --- src/dispatch/ai/enums.py | 13 ++ src/dispatch/ai/models.py | 36 ++++ src/dispatch/ai/service.py | 189 +++++++++++++++--- src/dispatch/case/type/models.py | 12 ++ .../versions/2025-07-08_f63ad392dbbf.py | 41 ++++ src/dispatch/event/service.py | 44 ++++ src/dispatch/incident/type/models.py | 12 ++ .../plugins/dispatch_slack/case/messages.py | 47 +---- .../dispatch_slack/incident/interactive.py | 103 ++++++++++ src/dispatch/plugins/dispatch_slack/plugin.py | 28 ++- .../plugins/dispatch_slack/service.py | 74 ++++++- .../dispatch/src/case/type/NewEditSheet.vue | 8 + .../static/dispatch/src/case/type/store.js | 1 + .../src/incident/type/NewEditSheet.vue | 8 + .../dispatch/src/incident/type/store.js | 1 + 15 files changed, 545 insertions(+), 72 deletions(-) create mode 100644 src/dispatch/ai/enums.py create mode 100644 src/dispatch/ai/models.py create mode 100644 src/dispatch/database/revisions/tenant/versions/2025-07-08_f63ad392dbbf.py diff --git a/src/dispatch/ai/enums.py b/src/dispatch/ai/enums.py new file mode 100644 index 000000000000..baf607d0a47e --- /dev/null +++ b/src/dispatch/ai/enums.py @@ -0,0 +1,13 @@ +from dispatch.enums import DispatchEnum + + +class AIEventSource(DispatchEnum): + """Source identifiers for AI-generated events.""" + + dispatch_genai = "Dispatch GenAI" + + +class AIEventDescription(DispatchEnum): + """Description templates for AI-generated events.""" + + read_in_summary_created = "AI-generated read-in summary created for {participant_email}" diff --git a/src/dispatch/ai/models.py b/src/dispatch/ai/models.py new file mode 100644 index 000000000000..d2f31df1abb5 --- /dev/null +++ b/src/dispatch/ai/models.py @@ -0,0 +1,36 @@ +from typing import List +from pydantic import Field + +from dispatch.models import DispatchBase + + +class ReadInSummary(DispatchBase): + """ + Model for structured read-in summary output from AI analysis. + + This model ensures the AI response is properly structured with timeline, + actions taken, and current status sections. + """ + + timeline: List[str] = Field( + description="Chronological list of key events and decisions", default_factory=list + ) + actions_taken: List[str] = Field( + description="List of actions that were taken to address the security event", + default_factory=list, + ) + current_status: str = Field( + description="Current status of the security event and any unresolved issues", default="" + ) + summary: str = Field(description="Overall summary of the security event", default="") + + +class ReadInSummaryResponse(DispatchBase): + """ + Response model for read-in summary generation. + + Includes the structured summary and any error messages. + """ + + summary: ReadInSummary | None = None + error_message: str | None = None diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index ccca6539e736..2fc53d7bef18 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -1,6 +1,8 @@ import json import logging +from dispatch.database.core import get_table_name_by_class_instance +from dispatch.plugins.dispatch_slack.models import IncidentSubjects import tiktoken from sqlalchemy.orm import aliased, Session @@ -9,16 +11,25 @@ from dispatch.enums import Visibility from dispatch.incident.models import Incident from dispatch.plugin import service as plugin_service +from dispatch.project.models import Project from dispatch.signal import service as signal_service from dispatch.tag.models import Tag, TagRecommendationResponse from dispatch.tag_type.models import TagType from dispatch.case import service as case_service from dispatch.incident import service as incident_service +from dispatch.types import Subject +from dispatch.event import service as event_service +from dispatch.enums import EventType from .exceptions import GenAIException +from .models import ReadInSummary, ReadInSummaryResponse +from .enums import AIEventSource, AIEventDescription log = logging.getLogger(__name__) +# Cache duration for AI-generated read-in summaries (in seconds) +READ_IN_SUMMARY_CACHE_DURATION = 120 # 2 minutes + def get_model_token_limit(model_name: str, buffer_percentage: float = 0.05) -> int: """ @@ -101,6 +112,18 @@ def truncate_prompt( return truncated_prompt +def prepare_prompt_for_model(prompt: str, model_name: str) -> str: + """ + Tokenizes and truncates the prompt if it exceeds the model's token limit. + Returns a prompt string that is safe to send to the model. + """ + tokenized_prompt, num_tokens, encoding = num_tokens_from_string(prompt, model_name) + model_token_limit = get_model_token_limit(model_name) + if num_tokens > model_token_limit: + prompt = truncate_prompt(tokenized_prompt, num_tokens, encoding, model_token_limit) + return prompt + + def generate_case_signal_historical_context(case: Case, db_session: Session) -> str: """ Generate historical context for a case stemming from a signal, including related cases and relevant data. @@ -278,17 +301,10 @@ def generate_case_signal_summary(case: Case, db_session: Session) -> dict[str, s """ - tokenized_prompt, num_tokens, encoding = num_tokens_from_string( + prompt = prepare_prompt_for_model( prompt, genai_plugin.instance.configuration.chat_completion_model ) - # we check if the prompt exceeds the token limit - model_token_limit = get_model_token_limit( - genai_plugin.instance.configuration.chat_completion_model - ) - if num_tokens > model_token_limit: - prompt = truncate_prompt(tokenized_prompt, num_tokens, encoding, model_token_limit) - # we generate the analysis response = genai_plugin.instance.chat_completion(prompt=prompt) @@ -372,23 +388,26 @@ def generate_incident_summary(incident: Incident, db_session: Session) -> str: {pir_doc} """ - tokenized_prompt, num_tokens, encoding = num_tokens_from_string( + prompt = prepare_prompt_for_model( prompt, genai_plugin.instance.configuration.chat_completion_model ) - # we check if the prompt exceeds the token limit - model_token_limit = get_model_token_limit( - genai_plugin.instance.configuration.chat_completion_model - ) - if num_tokens > model_token_limit: - prompt = truncate_prompt(tokenized_prompt, num_tokens, encoding, model_token_limit) - summary = genai_plugin.instance.chat_completion(prompt=prompt) incident.summary = summary db_session.add(incident) db_session.commit() + # Log the AI summary generation event + event_service.log_incident_event( + db_session=db_session, + source="Dispatch Core App", + description="AI-generated incident summary created", + incident_id=incident.id, + details={"summary": summary}, + type=EventType.other, + ) + return summary except Exception as e: @@ -529,17 +548,10 @@ def get_tag_recommendations( prompt += f"** Tags you can use: {tag_list} \n ** Security event details: {resources}" - tokenized_prompt, num_tokens, encoding = num_tokens_from_string( + prompt = prepare_prompt_for_model( prompt, genai_plugin.instance.configuration.chat_completion_model ) - # we check if the prompt exceeds the token limit - model_token_limit = get_model_token_limit( - genai_plugin.instance.configuration.chat_completion_model - ) - if num_tokens > model_token_limit: - prompt = truncate_prompt(tokenized_prompt, num_tokens, encoding, model_token_limit) - try: result = genai_plugin.instance.chat_completion(prompt=prompt) @@ -560,3 +572,132 @@ def get_tag_recommendations( log.exception(f"Error generating tag recommendations: {e}") message = "AI tag suggestions encountered an error. Please try again later." return TagRecommendationResponse(recommendations=[], error_message=message) + + +def generate_read_in_summary( + *, + db_session, + subject: Subject, + project: Project, + channel_id: str, + important_reaction: str, + participant_email: str = "", +) -> ReadInSummaryResponse: + """ + Generate a read-in summary for a subject. + + Args: + subject (Subject): The subject object for which the read-in summary is being generated. + project (Project): The project context. + channel_id (str): The channel ID to get conversation from. + important_reaction (str): The reaction to filter important messages. + participant_email (str): The email of the participant for whom the summary was generated. + + Returns: + ReadInSummaryResponse: A structured response containing the read-in summary or error message. + """ + subject_type = subject.type + + # Check for recent summary event + if subject_type == IncidentSubjects.incident: + recent_event = event_service.get_recent_summary_event( + db_session, incident_id=subject.id, max_age_seconds=READ_IN_SUMMARY_CACHE_DURATION + ) + else: + recent_event = event_service.get_recent_summary_event( + db_session, case_id=subject.id, max_age_seconds=READ_IN_SUMMARY_CACHE_DURATION + ) + + if recent_event and recent_event.details: + try: + summary = ReadInSummary(**recent_event.details) + return ReadInSummaryResponse(summary=summary) + except Exception as e: + log.warning( + f"Failed to parse cached summary from event {recent_event.id}: {e}. Generating new summary." + ) + + # Don't generate if no enabled ai plugin or storage plugin + 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"Read-in summary not generated for {subject.name}. No artificial-intelligence plugin enabled." + log.warning(message) + return ReadInSummaryResponse(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"Read-in summary not generated for {subject.name}. 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 read-in summaries. + Analyze the provided Slack channel messages and extract key information about a security event. + Focus on identifying: + 1. Timeline: Chronological list of key events and decisions (skip channel join/remove messages) + - For all timeline events, format timestamps as YYYY-MM-DD HH:MM (no seconds, no 'T'). + 2. Actions taken: List of actions that were taken to address the security event + 3. Current status: Current status of the security event and any unresolved issues + 4. Summary: Overall summary of the security event + + Only include the most relevant events and outcomes. Be clear and concise.""" + + prompt = f"""Analyze the following Slack channel messages regarding a security event and provide a structured summary. + + Slack messages: {conversation} + """ + + prompt = prepare_prompt_for_model( + prompt, genai_plugin.instance.configuration.chat_completion_model + ) + + try: + result = genai_plugin.instance.chat_parse( + prompt=prompt, response_model=ReadInSummary, system_message=system_message + ) + + # Log the AI read-in summary generation event + if subject.type == IncidentSubjects.incident: + # This is an incident + event_service.log_incident_event( + db_session=db_session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.read_in_summary_created.format( + participant_email=participant_email + ), + incident_id=subject.id, + details=result.dict(), + type=EventType.other, + ) + else: + # This is a case + event_service.log_case_event( + db_session=db_session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.read_in_summary_created.format( + participant_email=participant_email + ), + case_id=subject.id, + details=result.dict(), + type=EventType.other, + ) + + return ReadInSummaryResponse(summary=result) + + except Exception as e: + 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) diff --git a/src/dispatch/case/type/models.py b/src/dispatch/case/type/models.py index 9e6db2b12229..c3b44114ba9d 100644 --- a/src/dispatch/case/type/models.py +++ b/src/dispatch/case/type/models.py @@ -1,4 +1,5 @@ """Models for case types and related entities in the Dispatch application.""" + from pydantic import field_validator, AnyHttpUrl from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String @@ -19,6 +20,7 @@ class CaseType(ProjectMixin, Base): """SQLAlchemy model for case types, representing different types of cases in the system.""" + __table_args__ = (UniqueConstraint("name", "project_id"),) id = Column(Integer, primary_key=True) name = Column(String) @@ -30,6 +32,7 @@ class CaseType(ProjectMixin, Base): plugin_metadata = Column(JSON, default=[]) conversation_target = Column(String) auto_close = Column(Boolean, default=False, server_default=false()) + generate_read_in_summary = Column(Boolean, default=False, server_default=false()) # the catalog here is simple to help matching "named entities" search_vector = Column(TSVectorType("name", regconfig="pg_catalog.simple")) @@ -66,6 +69,7 @@ def get_meta(self, slug): # Pydantic models class Document(DispatchBase): """Pydantic model for a document related to a case type.""" + id: PrimaryKey description: str | None = None name: NameStr @@ -76,6 +80,7 @@ class Document(DispatchBase): class IncidentType(DispatchBase): """Pydantic model for an incident type related to a case type.""" + id: PrimaryKey description: str | None = None name: NameStr @@ -84,6 +89,7 @@ class IncidentType(DispatchBase): class Service(DispatchBase): """Pydantic model for a service related to a case type.""" + id: PrimaryKey description: str | None = None external_id: str @@ -94,6 +100,7 @@ class Service(DispatchBase): class CaseTypeBase(DispatchBase): """Base Pydantic model for case types, used for shared fields.""" + case_template_document: Document | None = None conversation_target: str | None = None default: bool | None = False @@ -108,6 +115,7 @@ class CaseTypeBase(DispatchBase): visibility: str | None = None cost_model: CostModelRead | None = None auto_close: bool | None = False + generate_read_in_summary: bool | None = False @field_validator("plugin_metadata", mode="before") @classmethod @@ -118,19 +126,23 @@ def replace_none_with_empty_list(cls, value): class CaseTypeCreate(CaseTypeBase): """Pydantic model for creating a new case type.""" + pass class CaseTypeUpdate(CaseTypeBase): """Pydantic model for updating an existing case type.""" + id: PrimaryKey | None = None class CaseTypeRead(CaseTypeBase): """Pydantic model for reading a case type from the database.""" + id: PrimaryKey class CaseTypePagination(Pagination): """Pydantic model for paginated case type results.""" + items: list[CaseTypeRead] = [] diff --git a/src/dispatch/database/revisions/tenant/versions/2025-07-08_f63ad392dbbf.py b/src/dispatch/database/revisions/tenant/versions/2025-07-08_f63ad392dbbf.py new file mode 100644 index 000000000000..951b51a7d742 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2025-07-08_f63ad392dbbf.py @@ -0,0 +1,41 @@ +"""Adds settings to case and incident types for generating read-in summaries. + +Revision ID: f63ad392dbbf +Revises: 5ed5defd1a55 +Create Date: 2025-07-08 13:56:35.033622 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f63ad392dbbf" +down_revision = "5ed5defd1a55" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "case_type", + sa.Column( + "generate_read_in_summary", sa.Boolean(), server_default=sa.text("false"), nullable=True + ), + ) + op.add_column( + "incident_type", + sa.Column( + "generate_read_in_summary", sa.Boolean(), server_default=sa.text("false"), nullable=True + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("incident_type", "generate_read_in_summary") + op.drop_column("case_type", "generate_read_in_summary") + # ### end Alembic commands ### diff --git a/src/dispatch/event/service.py b/src/dispatch/event/service.py index 88dd5125d610..1a0dad35b2e7 100644 --- a/src/dispatch/event/service.py +++ b/src/dispatch/event/service.py @@ -836,3 +836,47 @@ def export_case_timeline( raise Exception("No data to export, please check filter selection") return True + + +def get_recent_summary_event( + db_session, + case_id: int | None = None, + incident_id: int | None = None, + max_age_seconds: int = 300, +): # 5 minutes default + """Get the most recent AI read-in summary event for this subject.""" + from datetime import datetime, timedelta + from dispatch.ai.enums import AIEventSource, AIEventDescription + + cutoff_time = datetime.utcnow() - timedelta(seconds=max_age_seconds) + + if incident_id: + # This is an incident + return ( + db_session.query(Event) + .filter(Event.incident_id == incident_id) + .filter(Event.source == AIEventSource.dispatch_genai) + .filter( + Event.description.like( + f"{AIEventDescription.read_in_summary_created.format(participant_email='')}%" + ) + ) + .filter(Event.started_at >= cutoff_time) + .order_by(Event.started_at.desc()) + .first() + ) + else: + # This is a case + return ( + db_session.query(Event) + .filter(Event.case_id == case_id) + .filter(Event.source == AIEventSource.dispatch_genai) + .filter( + Event.description.like( + f"{AIEventDescription.read_in_summary_created.format(participant_email='')}%" + ) + ) + .filter(Event.started_at >= cutoff_time) + .order_by(Event.started_at.desc()) + .first() + ) diff --git a/src/dispatch/incident/type/models.py b/src/dispatch/incident/type/models.py index d147838203c5..a46dc79eda91 100644 --- a/src/dispatch/incident/type/models.py +++ b/src/dispatch/incident/type/models.py @@ -6,6 +6,7 @@ from sqlalchemy.event import listen from sqlalchemy.ext.hybrid import hybrid_method from sqlalchemy.orm import relationship +from sqlalchemy.sql import false from sqlalchemy.sql.schema import UniqueConstraint from sqlalchemy_utils import TSVectorType @@ -19,8 +20,10 @@ from dispatch.project.models import ProjectRead from dispatch.service.models import ServiceRead + class IncidentType(ProjectMixin, Base): """SQLAlchemy model for incident type resources.""" + __table_args__ = (UniqueConstraint("name", "project_id"),) id = Column(Integer, primary_key=True) name = Column(String) @@ -35,6 +38,7 @@ class IncidentType(ProjectMixin, Base): exclude_from_review = Column(Boolean, default=False) plugin_metadata = Column(JSON, default=[]) task_plugin_metadata = Column(JSON, default=[]) + generate_read_in_summary = Column(Boolean, default=False, server_default=false()) incident_template_document_id = Column(Integer, ForeignKey("document.id")) incident_template_document = relationship( @@ -99,6 +103,7 @@ def get_task_meta(self, slug): class Document(DispatchBase): """Pydantic model for a document related to an incident type.""" + id: PrimaryKey name: NameStr resource_type: str | None = None @@ -110,6 +115,7 @@ class Document(DispatchBase): # Pydantic models... class IncidentTypeBase(DispatchBase): """Base Pydantic model for incident type resources.""" + name: NameStr visibility: str | None = None description: str | None = None @@ -128,6 +134,7 @@ class IncidentTypeBase(DispatchBase): channel_description: str | None = None description_service: ServiceRead | None = None task_plugin_metadata: list[PluginMetadata] = [] + generate_read_in_summary: bool | None = False @field_validator("plugin_metadata", mode="before") @classmethod @@ -138,21 +145,25 @@ def replace_none_with_empty_list(cls, value): class IncidentTypeCreate(IncidentTypeBase): """Pydantic model for creating an incident type resource.""" + pass class IncidentTypeUpdate(IncidentTypeBase): """Pydantic model for updating an incident type resource.""" + id: PrimaryKey | None = None class IncidentTypeRead(IncidentTypeBase): """Pydantic model for reading an incident type resource.""" + id: PrimaryKey class IncidentTypeReadMinimal(DispatchBase): """Pydantic model for reading a minimal incident type resource.""" + id: PrimaryKey name: NameStr visibility: str | None = None @@ -164,4 +175,5 @@ class IncidentTypeReadMinimal(DispatchBase): class IncidentTypePagination(Pagination): """Pydantic model for paginated incident type results.""" + items: list[IncidentTypeRead] = [] diff --git a/src/dispatch/plugins/dispatch_slack/case/messages.py b/src/dispatch/plugins/dispatch_slack/case/messages.py index 9061fd951c9d..c6baadc51a88 100644 --- a/src/dispatch/plugins/dispatch_slack/case/messages.py +++ b/src/dispatch/plugins/dispatch_slack/case/messages.py @@ -11,6 +11,7 @@ Section, ) from blockkit.surfaces import Block +from dispatch.plugins.dispatch_slack.service import create_genai_message_metadata_blocks from sqlalchemy.orm import Session from dispatch.ai import service as ai_service @@ -325,48 +326,6 @@ def create_action_buttons_message( return Message(blocks=signal_metadata_blocks).build()["blocks"] -def json_to_slack_format(json_message: dict[str, str]) -> str: - """ - Converts a JSON dictionary to Slack markup format. - - Args: - json_dict (dict): The JSON dictionary to convert. - - Returns: - str: A string formatted with Slack markup. - """ - slack_message = "" - for key, value in json_message.items(): - slack_message += f"*{key}*\n{value}\n\n" - return slack_message.strip() - - -def create_genai_signal_message_metadata_blocks( - signal_metadata_blocks: list[Block], message: str | dict[str, str] -) -> list[Block]: - """ - Appends a GenAI signal analysis section to the signal metadata blocks. - - Args: - signal_metadata_blocks (list[Block]): The list of existing signal metadata blocks. - message (str | dict[str, str]): The GenAI analysis message, either as a string or a dictionary. - - Returns: - list[Block]: The updated list of signal metadata blocks with the GenAI analysis section appended. - """ - if isinstance(message, dict): - message = json_to_slack_format(message) - - # Truncate the text if it exceeds Block Kit's maximum length of 3000 characters - text = f":magic_wand: *GenAI Alert Analysis*\n\n{message}" - text = f"{text[:2997]}..." if len(text) > 3000 else text - signal_metadata_blocks.append( - Section(text=text), - ) - signal_metadata_blocks.append(Divider()) - return Message(blocks=signal_metadata_blocks).build()["blocks"] - - def create_genai_signal_analysis_message( case: Case, db_session: Session, @@ -393,7 +352,9 @@ def create_genai_signal_analysis_message( "We encountered an error while generating the GenAI analysis summary for this case." ) log.warning(f"Error generating GenAI analysis summary for case {case.id}. Error: {e}") - return summary, create_genai_signal_message_metadata_blocks(signal_metadata_blocks, summary) + return summary, create_genai_message_metadata_blocks( + title="GenAI Alert Analysis", blocks=signal_metadata_blocks, message=summary + ) def create_signal_engagement_message( diff --git a/src/dispatch/plugins/dispatch_slack/incident/interactive.py b/src/dispatch/plugins/dispatch_slack/incident/interactive.py index 8bed0b649b65..427882c6ce99 100644 --- a/src/dispatch/plugins/dispatch_slack/incident/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/incident/interactive.py @@ -1,5 +1,6 @@ import logging import re +import time import uuid from functools import partial from datetime import datetime, timedelta @@ -27,6 +28,8 @@ from slack_sdk.web.client import WebClient from sqlalchemy.orm import Session +from dispatch.ai import service as ai_service +from dispatch.ai.models import ReadInSummary from dispatch.auth.models import DispatchUser from dispatch.case import service as case_service from dispatch.case import flows as case_flows @@ -780,6 +783,52 @@ def replace_slack_users_in_message(client: Any, message: str) -> str: return re.sub(r"<@([^>]+)>", lambda x: f"@{get_user_name_from_id(client, x.group(1))}", message) +def create_read_in_summary_blocks(summary: ReadInSummary) -> list: + """Creates Slack blocks from a structured read-in summary.""" + blocks = [] + + # Add AI disclaimer at the top + blocks.append( + Context( + elements=[ + MarkdownText( + text=":sparkles: *This entire block is AI-generated and may contain errors or inaccuracies. Please verify the information before relying on it.*" + ) + ] + ).build() + ) + + # Add AI-generated summary if available + if summary.summary: + blocks.append( + Section(text=":magic_wand: *AI-Generated Summary*\n{0}".format(summary.summary)).build() + ) + + # Add timeline events if available + if summary.timeline: + timeline_text = "\n".join([f"• {event}" for event in summary.timeline]) + blocks.append( + Section( + text=":alarm_clock: *Timeline* _(times in UTC)_\n{0}".format(timeline_text) + ).build() + ) + + # Add actions taken if available + if summary.actions_taken: + actions_text = "\n".join([f"• {action}" for action in summary.actions_taken]) + blocks.append( + Section(text=":white_check_mark: *Actions Taken*\n{0}".format(actions_text)).build() + ) + + # Add current status if available + if summary.current_status: + blocks.append( + Section(text=":bar_chart: *Current Status*\n{0}".format(summary.current_status)).build() + ) + + return blocks + + def handle_timeline_added_event( ack: Ack, client: Any, context: BoltContext, payload: Any, db_session: Session ) -> None: @@ -1122,6 +1171,13 @@ def handle_member_joined_channel( "Unable to handle member_joined_channel Slack event. Dispatch user unknown." ) + # sleep for a second to allow the participant to be added to the incident + time.sleep(1) + + generate_read_in_summary = False + subject_type = "incident" + project = None + if context["subject"].type == IncidentSubjects.incident: participant = incident_flows.incident_add_or_reactivate_participant_flow( user_email=user.email, incident_id=int(context["subject"].id), db_session=db_session @@ -1136,6 +1192,12 @@ def handle_member_joined_channel( incident = incident_service.get( db_session=db_session, incident_id=int(context["subject"].id) ) + project = incident.project + generate_read_in_summary = getattr( + incident.incident_type, "generate_read_in_summary", False + ) + if incident.visibility == Visibility.restricted: + generate_read_in_summary = False # If the user was invited, the message will include an inviter property containing the user ID of the inviting user. # The property will be absent when a user manually joins a channel, or a user is added by default (e.g. #general channel). @@ -1176,6 +1238,7 @@ def handle_member_joined_channel( db_session.commit() if context["subject"].type == CaseSubjects.case: + subject_type = "case" case = case_service.get(db_session=db_session, case_id=int(context["subject"].id)) if not case.dedicated_channel: @@ -1191,6 +1254,11 @@ def handle_member_joined_channel( # Participant is already in the case channel. return + project = case.project + generate_read_in_summary = getattr(case.case_type, "generate_read_in_summary", False) + if case.visibility == Visibility.restricted: + generate_read_in_summary = False + participant.user_conversation_id = context["user_id"] # If the user was invited, the message will include an inviter property containing the user ID of the inviting user. @@ -1228,6 +1296,41 @@ def handle_member_joined_channel( db_session.add(participant) db_session.commit() + if not generate_read_in_summary: + return + + # Generate read-in summary for user + summary_response = ai_service.generate_read_in_summary( + db_session=db_session, + subject=context["subject"], + project=project, + channel_id=context["channel_id"], + important_reaction=context["config"].timeline_event_reaction, + participant_email=user.email, + ) + + if summary_response and summary_response.summary: + blocks = create_read_in_summary_blocks(summary_response.summary) + blocks.append( + Context( + elements=[ + MarkdownText( + text="NOTE: The block above was AI-generated and may contain errors or inaccuracies. Please verify the information before relying on it." + ) + ] + ).build() + ) + dispatch_slack_service.send_ephemeral_message( + client=client, + conversation_id=context["channel_id"], + user_id=context["user_id"], + text=f"Here is a summary of what has happened so far in this {subject_type}", + blocks=blocks, + ) + elif summary_response and summary_response.error_message: + # Log the error but don't show it to the user to avoid confusion + log.warning(f"Failed to generate read-in summary: {summary_response.error_message}") + @app.event( "member_left_channel", diff --git a/src/dispatch/plugins/dispatch_slack/plugin.py b/src/dispatch/plugins/dispatch_slack/plugin.py index 5e10e0fafc2d..906fb5b6bf09 100644 --- a/src/dispatch/plugins/dispatch_slack/plugin.py +++ b/src/dispatch/plugins/dispatch_slack/plugin.py @@ -51,6 +51,7 @@ create_slack_client, does_user_exist, emails_to_user_ids, + get_channel_activity, get_user_avatar_url, get_user_info_by_id, get_user_profile_by_email, @@ -442,9 +443,34 @@ def fetch_events( activity = event_instance.fetch_activity(client, subject, oldest) return activity except Exception as e: - logger.exception("An error occurred while fetching incident or case events from the Slack plugin.", exc_info=e) + logger.exception( + "An error occurred while fetching incident or case events from the Slack plugin.", + exc_info=e, + ) raise + def get_conversation( + self, conversation_id: str, oldest: str = "0", important_reaction: str | None = None + ) -> list: + """ + Fetches the top-level posts from a Slack conversation. + + Args: + conversation_id (str): The ID of the Slack conversation. + oldest (str): The oldest timestamp to fetch messages from. + + Returns: + list: A list of tuples containing the timestamp and user ID of each message. + """ + client = create_slack_client(self.configuration) + return get_channel_activity( + client, + conversation_id, + oldest, + include_message_text=True, + important_reaction=important_reaction, + ) + def get_conversation_replies(self, conversation_id: str, thread_ts: str) -> list[str]: """ Fetches replies from a specific thread in a Slack conversation. diff --git a/src/dispatch/plugins/dispatch_slack/service.py b/src/dispatch/plugins/dispatch_slack/service.py index f3f7fedd2094..41fb529ee104 100644 --- a/src/dispatch/plugins/dispatch_slack/service.py +++ b/src/dispatch/plugins/dispatch_slack/service.py @@ -3,7 +3,8 @@ import logging from datetime import datetime -from blockkit import Message, Section +from blockkit.surfaces import Block +from blockkit import Divider, Message, Section from requests import Timeout from slack_sdk.errors import SlackApiError from slack_sdk.web.client import WebClient @@ -595,11 +596,27 @@ def get_thread_activity( return heapq.nsmallest(len(result), result) -def get_channel_activity(client: WebClient, conversation_id: str, oldest: str = "0") -> list: +def has_important_reaction(message, important_reaction): + if not important_reaction: + return False + for reaction in message.get("reactions", []): + if reaction["name"] == important_reaction: + return True + return False + + +def get_channel_activity( + client: WebClient, + conversation_id: str, + oldest: str = "0", + include_message_text: bool = False, + important_reaction: str | None = None, +) -> list: """Gets all top-level messages for a given Slack channel. Returns: - A sorted list of tuples (utc_dt, user_id) of each message in the channel. + 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. """ result = [] cursor = None @@ -622,10 +639,59 @@ def get_channel_activity(client: WebClient, conversation_id: str, oldest: str = # Resolves users for messages. if "user" in message: user_id = resolve_user(client, message["user"])["id"] - heapq.heappush(result, (datetime.utcfromtimestamp(float(message["ts"])), user_id)) + utc_dt = datetime.utcfromtimestamp(float(message["ts"])) + 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)) if not response["has_more"]: break cursor = response["response_metadata"]["next_cursor"] return heapq.nsmallest(len(result), result) + + +def json_to_slack_format(json_message: dict[str, str]) -> str: + """ + Converts a JSON dictionary to Slack markup format. + + Args: + json_dict (dict): The JSON dictionary to convert. + + Returns: + str: A string formatted with Slack markup. + """ + slack_message = "" + for key, value in json_message.items(): + slack_message += f"*{key}*\n{value}\n\n" + return slack_message.strip() + + +def create_genai_message_metadata_blocks( + title: str, blocks: list[Block], message: str | dict[str, str] +) -> list[Block]: + """ + Appends a GenAI section to any existing metadata blocks. + + Args: + blocks (list[Block]): The list of existing metadata blocks. + message (str | dict[str, str]): The GenAI message, either as a string or a dictionary. + + Returns: + list[Block]: The updated list of metadata blocks with the GenAI section appended. + """ + if isinstance(message, dict): + message = json_to_slack_format(message) + + # Truncate the text if it exceeds Block Kit's maximum length of 3000 characters + text = f":magic_wand: *{title}*\n\n{message}" + text = f"{text[:2997]}..." if len(text) > 3000 else text + blocks.append( + Section(text=text), + ) + blocks.append(Divider()) + return Message(blocks=blocks).build()["blocks"] diff --git a/src/dispatch/static/dispatch/src/case/type/NewEditSheet.vue b/src/dispatch/static/dispatch/src/case/type/NewEditSheet.vue index 6767f6d60023..952b1f812f65 100644 --- a/src/dispatch/static/dispatch/src/case/type/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/case/type/NewEditSheet.vue @@ -139,6 +139,13 @@ hint="Check if this case type should be excluded from all metrics." /> + + + @@ -199,6 +206,7 @@ export default { "selected.description", "selected.enabled", "selected.exclude_from_metrics", + "selected.generate_read_in_summary", "selected.id", "selected.incident_type", "selected.loading", diff --git a/src/dispatch/static/dispatch/src/case/type/store.js b/src/dispatch/static/dispatch/src/case/type/store.js index 7badfa11dde2..d8040ad07715 100644 --- a/src/dispatch/static/dispatch/src/case/type/store.js +++ b/src/dispatch/static/dispatch/src/case/type/store.js @@ -23,6 +23,7 @@ const getDefaultSelectedState = () => { project: null, slug: null, visibility: null, + generate_read_in_summary: false, } } diff --git a/src/dispatch/static/dispatch/src/incident/type/NewEditSheet.vue b/src/dispatch/static/dispatch/src/incident/type/NewEditSheet.vue index de7fb680420a..8cefd9af507a 100644 --- a/src/dispatch/static/dispatch/src/incident/type/NewEditSheet.vue +++ b/src/dispatch/static/dispatch/src/incident/type/NewEditSheet.vue @@ -118,6 +118,13 @@ hint="Check if this incident type should be excluded from all metrics." /> + + + { default: false, project: null, cost_model: null, + generate_read_in_summary: false, } } From bc165c4fccd358bb67e7ef390c834073037a9978 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Wed, 9 Jul 2025 10:51:34 -0700 Subject: [PATCH 2/6] Adds chat_parse to artifical-intelligence plugin interface --- src/dispatch/ai/service.py | 6 ++-- .../plugins/bases/artificial_intelligence.py | 3 ++ .../plugins/dispatch_openai/plugin.py | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index 2fc53d7bef18..f16f287553de 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -645,7 +645,7 @@ def generate_read_in_summary( return ReadInSummaryResponse(error_message=message) system_message = """You are a cybersecurity analyst tasked with creating structured read-in summaries. - Analyze the provided Slack channel messages and extract key information about a security event. + Analyze the provided channel messages and extract key information about a security event. Focus on identifying: 1. Timeline: Chronological list of key events and decisions (skip channel join/remove messages) - For all timeline events, format timestamps as YYYY-MM-DD HH:MM (no seconds, no 'T'). @@ -655,9 +655,9 @@ def generate_read_in_summary( Only include the most relevant events and outcomes. Be clear and concise.""" - prompt = f"""Analyze the following Slack channel messages regarding a security event and provide a structured summary. + prompt = f"""Analyze the following channel messages regarding a security event and provide a structured summary. - Slack messages: {conversation} + Channel messages: {conversation} """ prompt = prepare_prompt_for_model( diff --git a/src/dispatch/plugins/bases/artificial_intelligence.py b/src/dispatch/plugins/bases/artificial_intelligence.py index 247910be113e..a136122d0f03 100644 --- a/src/dispatch/plugins/bases/artificial_intelligence.py +++ b/src/dispatch/plugins/bases/artificial_intelligence.py @@ -15,5 +15,8 @@ class ArtificialIntelligencePlugin(Plugin): def chat_completion(self, items, **kwargs): raise NotImplementedError + def chat_parse(self, items, **kwargs): + raise NotImplementedError + def list_models(self, items, **kwargs): raise NotImplementedError diff --git a/src/dispatch/plugins/dispatch_openai/plugin.py b/src/dispatch/plugins/dispatch_openai/plugin.py index 227b85f9d653..a7103fd12cf3 100644 --- a/src/dispatch/plugins/dispatch_openai/plugin.py +++ b/src/dispatch/plugins/dispatch_openai/plugin.py @@ -9,6 +9,7 @@ import logging from openai import OpenAI +from typing import TypeVar, Type from dispatch.decorators import apply, counter, timer from dispatch.plugins import dispatch_openai as openai_plugin @@ -16,6 +17,7 @@ from dispatch.plugins.dispatch_openai.config import ( OpenAIConfiguration, ) +from pydantic import BaseModel logger = logging.getLogger(__name__) @@ -56,3 +58,29 @@ def chat_completion(self, prompt: str) -> dict: raise return completion.choices[0].message + + T = TypeVar("T", bound=BaseModel) + + def chat_parse(self, prompt: str, response_model: Type[T]) -> T: + client = OpenAI(api_key=self.api_key) + + try: + completion = client.chat.completions.parse( + model=self.model, + response_format=response_model, + messages=[ + { + "role": "system", + "content": self.system_message, + }, + { + "role": "user", + "content": prompt, + }, + ], + ) + except Exception as e: + logger.error(e) + raise + + return completion.choices[0].message.parsed From dbd18628bcdaf23fd6af3226d7888ea7935ee374 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Wed, 9 Jul 2025 11:28:03 -0700 Subject: [PATCH 3/6] adding tests around generate_read_in_summary --- tests/ai/__init__.py | 1 + tests/ai/test_ai_service.py | 475 ++++++++++++++++++++++++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 tests/ai/__init__.py create mode 100644 tests/ai/test_ai_service.py diff --git a/tests/ai/__init__.py b/tests/ai/__init__.py new file mode 100644 index 000000000000..92025db97f3c --- /dev/null +++ b/tests/ai/__init__.py @@ -0,0 +1 @@ +# AI tests package diff --git a/tests/ai/test_ai_service.py b/tests/ai/test_ai_service.py new file mode 100644 index 000000000000..05770370a1c7 --- /dev/null +++ b/tests/ai/test_ai_service.py @@ -0,0 +1,475 @@ +import pytest +from unittest.mock import Mock, patch, MagicMock +from datetime import datetime, timedelta + +from dispatch.ai.service import generate_read_in_summary, READ_IN_SUMMARY_CACHE_DURATION +from dispatch.ai.models import ReadInSummary, ReadInSummaryResponse +from dispatch.ai.enums import AIEventSource, AIEventDescription +from dispatch.plugins.dispatch_slack.models import IncidentSubjects, CaseSubjects +from dispatch.enums import EventType +from dispatch.types import Subject + + +class TestGenerateReadInSummary: + """Test suite for generate_read_in_summary function.""" + + @pytest.fixture + def mock_subject(self): + """Create a mock subject for testing.""" + subject = Mock(spec=Subject) + subject.id = 123 + subject.name = "Test Incident" + subject.type = IncidentSubjects.incident + return subject + + @pytest.fixture + def mock_project(self): + """Create a mock project for testing.""" + project = Mock() + project.id = 456 + return project + + @pytest.fixture + def mock_conversation(self): + """Create mock conversation data.""" + return [ + {"user": "user1", "text": "Alert received", "timestamp": "2024-01-01 10:00"}, + {"user": "user2", "text": "Investigating the issue", "timestamp": "2024-01-01 10:05"}, + {"user": "user1", "text": "Issue resolved", "timestamp": "2024-01-01 10:30"}, + ] + + @pytest.fixture + def mock_read_in_summary(self): + """Create a mock ReadInSummary object.""" + return ReadInSummary( + timeline=["2024-01-01 10:00: Alert received", "2024-01-01 10:30: Issue resolved"], + actions_taken=["Investigated the alert", "Applied fix"], + current_status="Resolved", + summary="Security alert was investigated and resolved successfully", + ) + + def test_generate_read_in_summary_success_incident( + self, session, mock_subject, mock_project, mock_conversation, mock_read_in_summary + ): + """Test successful read-in summary generation for an incident.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + 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 no recent event (cache miss) + mock_get_event.return_value = None + + # 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_read_in_summary + + # 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_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is not None + assert result.error_message is None + assert result.summary.timeline == mock_read_in_summary.timeline + assert result.summary.actions_taken == mock_read_in_summary.actions_taken + assert result.summary.current_status == mock_read_in_summary.current_status + assert result.summary.summary == mock_read_in_summary.summary + + # Verify event logging + mock_log_event.assert_called_once_with( + db_session=session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.read_in_summary_created.format( + participant_email="test@example.com" + ), + incident_id=mock_subject.id, + details=mock_read_in_summary.dict(), + type=EventType.other, + ) + + def test_generate_read_in_summary_success_case( + self, session, mock_subject, mock_project, mock_conversation, mock_read_in_summary + ): + """Test successful read-in summary generation for a case.""" + # Change subject type to case + mock_subject.type = CaseSubjects.case + + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.event_service.log_case_event") as mock_log_event, + ): + + # Mock no recent event (cache miss) + mock_get_event.return_value = None + + # 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_read_in_summary + + # 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_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is not None + assert result.error_message is None + + # Verify case event logging + mock_log_event.assert_called_once_with( + db_session=session, + source=AIEventSource.dispatch_genai, + description=AIEventDescription.read_in_summary_created.format( + participant_email="test@example.com" + ), + case_id=mock_subject.id, + details=mock_read_in_summary.dict(), + type=EventType.other, + ) + + def test_generate_read_in_summary_cache_hit( + self, session, mock_subject, mock_project, mock_read_in_summary + ): + """Test read-in summary generation with cache hit.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + ): + + # Mock recent event (cache hit) + mock_event = Mock() + mock_event.id = 789 + mock_event.details = mock_read_in_summary.dict() + mock_get_event.return_value = mock_event + + # Call the function + result = generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is not None + assert result.error_message is None + assert result.summary.timeline == mock_read_in_summary.timeline + + # Verify no plugins were called (cache hit) + mock_get_plugin.assert_not_called() + + def test_generate_read_in_summary_cache_invalid_data( + self, session, mock_subject, mock_project, mock_conversation, mock_read_in_summary + ): + """Test read-in summary generation with invalid cached data.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + + # Mock recent event with invalid data + mock_event = Mock() + mock_event.id = 789 + mock_event.details = {"timeline": "not a list"} # This will cause validation error + mock_get_event.return_value = mock_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_read_in_summary + + # 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 + generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Verify warning was logged + assert mock_log.warning.called + + def test_generate_read_in_summary_no_ai_plugin(self, session, mock_subject, mock_project): + """Test read-in summary generation when no AI plugin is available.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + + # Mock no recent event + mock_get_event.return_value = None + + # Mock no AI plugin + mock_get_plugin.return_value = None + + # Call the function + result = generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is None + assert result.error_message is not None + assert "No artificial-intelligence plugin enabled" in result.error_message + + # Verify warning was logged + mock_log.warning.assert_called() + + def test_generate_read_in_summary_no_conversation_plugin( + self, session, mock_subject, mock_project + ): + """Test read-in summary generation when no conversation plugin is available.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + + # Mock no recent event + mock_get_event.return_value = None + + # Mock AI plugin but 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 + ) + + # Call the function + result = generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is None + assert result.error_message is not None + assert "No conversation plugin enabled" in result.error_message + + # Verify warning was logged + mock_log.warning.assert_called() + + def test_generate_read_in_summary_no_conversation(self, session, mock_subject, mock_project): + """Test read-in summary generation when no conversation is found.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + + # Mock no recent event + mock_get_event.return_value = None + + # Mock AI plugin + mock_ai_plugin = Mock() + mock_ai_plugin.instance.configuration.chat_completion_model = "gpt-4" + + # Mock conversation plugin that returns no conversation + mock_conv_plugin = Mock() + mock_conv_plugin.instance.get_conversation.return_value = None + + # 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_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is None + assert result.error_message is not None + assert "No conversation found" in result.error_message + + # Verify warning was logged + mock_log.warning.assert_called() + + def test_generate_read_in_summary_ai_error( + self, session, mock_subject, mock_project, mock_conversation + ): + """Test read-in summary generation when AI plugin throws an error.""" + with ( + patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event, + patch("dispatch.ai.service.plugin_service.get_active_instance") as mock_get_plugin, + patch("dispatch.ai.service.log") as mock_log, + ): + + # Mock no recent event + mock_get_event.return_value = None + + # Mock AI plugin that throws an 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 service error") + + # 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_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Assertions + assert isinstance(result, ReadInSummaryResponse) + assert result.summary is None + assert result.error_message is not None + assert "Error generating read-in summary" in result.error_message + + # Verify error was logged + mock_log.exception.assert_called() + + def test_generate_read_in_summary_cache_duration_constant(self): + """Test that the cache duration constant is set correctly.""" + assert READ_IN_SUMMARY_CACHE_DURATION == 120 # 2 minutes + + def test_generate_read_in_summary_event_query_incident( + self, session, mock_subject, mock_project + ): + """Test that the correct event query is made for incidents.""" + with patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event: + mock_get_event.return_value = None + + # Call the function + generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Verify the correct query was made + mock_get_event.assert_called_once_with( + session, incident_id=mock_subject.id, max_age_seconds=READ_IN_SUMMARY_CACHE_DURATION + ) + + def test_generate_read_in_summary_event_query_case(self, session, mock_subject, mock_project): + """Test that the correct event query is made for cases.""" + # Change subject type to case + mock_subject.type = CaseSubjects.case + + with patch("dispatch.ai.service.event_service.get_recent_summary_event") as mock_get_event: + mock_get_event.return_value = None + + # Call the function + generate_read_in_summary( + db_session=session, + subject=mock_subject, + project=mock_project, + channel_id="test-channel", + important_reaction=":white_check_mark:", + participant_email="test@example.com", + ) + + # Verify the correct query was made + mock_get_event.assert_called_once_with( + session, case_id=mock_subject.id, max_age_seconds=READ_IN_SUMMARY_CACHE_DURATION + ) From 474d451fd1499f6d29e0f26c25cf1cbefb330e6d Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Wed, 9 Jul 2025 11:28:44 -0700 Subject: [PATCH 4/6] removing unused import --- src/dispatch/ai/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dispatch/ai/service.py b/src/dispatch/ai/service.py index f16f287553de..e1805c0b8c15 100644 --- a/src/dispatch/ai/service.py +++ b/src/dispatch/ai/service.py @@ -1,7 +1,6 @@ import json import logging -from dispatch.database.core import get_table_name_by_class_instance from dispatch.plugins.dispatch_slack.models import IncidentSubjects import tiktoken from sqlalchemy.orm import aliased, Session From 15ee2c54b17b78d5290a84affff8103b6fa4ed91 Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Wed, 9 Jul 2025 11:32:17 -0700 Subject: [PATCH 5/6] removing unused imports --- tests/ai/test_ai_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/ai/test_ai_service.py b/tests/ai/test_ai_service.py index 05770370a1c7..4e9d996702c2 100644 --- a/tests/ai/test_ai_service.py +++ b/tests/ai/test_ai_service.py @@ -1,6 +1,5 @@ import pytest -from unittest.mock import Mock, patch, MagicMock -from datetime import datetime, timedelta +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 81137154e14bac2199b2fb47a540640ecc48320b Mon Sep 17 00:00:00 2001 From: David Whittaker Date: Thu, 10 Jul 2025 11:28:10 -0700 Subject: [PATCH 6/6] update node version for playwright --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b2d77f1c00fd..e70b7e42efae 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -36,7 +36,7 @@ jobs: python-version: 3.11 - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 18 - uses: actions/cache@v4 with: path: ~/.cache/pip