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
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..e1805c0b8c15 100644
--- a/src/dispatch/ai/service.py
+++ b/src/dispatch/ai/service.py
@@ -1,6 +1,7 @@
import json
import logging
+from dispatch.plugins.dispatch_slack.models import IncidentSubjects
import tiktoken
from sqlalchemy.orm import aliased, Session
@@ -9,16 +10,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 +111,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 +300,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 +387,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 +547,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 +571,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 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 channel messages regarding a security event and provide a structured summary.
+
+ Channel 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/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
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,
}
}
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..4e9d996702c2
--- /dev/null
+++ b/tests/ai/test_ai_service.py
@@ -0,0 +1,474 @@
+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.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
+ )