Skip to content

Commit 3654fa6

Browse files
committed
feat: add SLA breach reporting functionality and related API endpoints
Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent 8358afb commit 3654fa6

4 files changed

Lines changed: 242 additions & 25 deletions

File tree

backend/app.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,28 @@ async def get_csv_ticket_stats():
623623
})
624624

625625

626+
@app.route("/api/csv-tickets/sla-breach", methods=["GET"])
627+
async def get_csv_tickets_sla_breach():
628+
"""
629+
Return unassigned tickets grouped by SLA breach status (breached → at_risk),
630+
sorted by age_hours descending within each group.
631+
632+
Query params:
633+
- unassigned_only: true/false (default: true)
634+
- include_ok: true/false (default: false) — include non-breached tickets too
635+
"""
636+
from tickets import get_sla_breach_report
637+
638+
unassigned_only = request.args.get("unassigned_only", "true").lower() != "false"
639+
include_ok = request.args.get("include_ok", "false").lower() == "true"
640+
641+
tickets = _csv_ticket_service.list_tickets(
642+
has_assignee=False if unassigned_only else None,
643+
)
644+
report = get_sla_breach_report(tickets, reference_time=None, include_ok=include_ok)
645+
return jsonify(report.model_dump(mode="json"))
646+
647+
626648
@app.route("/api/health", methods=["GET"])
627649
async def health_check():
628650
"""Health check endpoint."""

backend/operations.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
from api_decorators import operation
1313
from csv_data import get_csv_ticket_service
1414
from tasks import Task, TaskCreate, TaskFilter, TaskService, TaskStats, TaskUpdate
15-
from tickets import Ticket, TicketStatus
15+
from tickets import (
16+
SlaBreachReport,
17+
Ticket,
18+
TicketSlaInfo,
19+
TicketStatus,
20+
get_sla_breach_report,
21+
)
1622

1723
# Service instances shared across interfaces
1824
_task_service = TaskService()
@@ -272,6 +278,44 @@ async def op_csv_ticket_fields() -> list[dict[str, str]]:
272278
return CSV_TICKET_FIELDS
273279

274280

281+
@operation(
282+
name="csv_sla_breach_tickets",
283+
description=(
284+
"Return tickets at SLA breach risk from the CSV dataset. "
285+
"By default only unassigned tickets (assigned to a group but no individual) are included. "
286+
"Results contain pre-computed age_hours, sla_threshold_hours, and breach_status. "
287+
"Grouped: 'breached' first, then 'at_risk'. Within each group sorted by age_hours descending. "
288+
"The reference timestamp is the maximum created_at date found in the selected tickets "
289+
"(not the current system time), making results deterministic for historical datasets. "
290+
"SLA thresholds: critical=4h, high=24h, medium=72h, low=120h."
291+
),
292+
http_method="GET",
293+
)
294+
async def op_csv_sla_breach_tickets(
295+
unassigned_only: bool = True,
296+
include_ok: bool = False,
297+
) -> SlaBreachReport:
298+
"""
299+
Pre-compute SLA breach status for CSV tickets.
300+
301+
Args:
302+
unassigned_only: When True (default), only return tickets that are assigned
303+
to a group but have no individual assignee — the primary use case for
304+
proactive SLA monitoring.
305+
include_ok: When True, also include tickets that are within their SLA window.
306+
Default False keeps the result focused on actionable items.
307+
308+
Returns:
309+
SlaBreachReport with reference_timestamp, counts, and a sorted list of
310+
TicketSlaInfo objects ready for display or further AI commentary.
311+
"""
312+
_ensure_csv_loaded()
313+
tickets = _csv_service.list_tickets(
314+
has_assignee=False if unassigned_only else None,
315+
)
316+
return get_sla_breach_report(tickets, reference_time=None, include_ok=include_ok)
317+
318+
275319
# Export shared services for callers (REST app, CLI tools, etc.)
276320
task_service = _task_service
277321
csv_ticket_service = _csv_service
@@ -290,5 +334,6 @@ async def op_csv_ticket_fields() -> list[dict[str, str]]:
290334
"op_csv_search_tickets",
291335
"op_csv_ticket_stats",
292336
"op_csv_ticket_fields",
337+
"op_csv_sla_breach_tickets",
293338
"CSV_TICKET_FIELDS",
294339
]

backend/tickets.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class WorkLogType(str, Enum):
5757

5858

5959
# ============================================================================
60-
# PRIORITY SLA DEADLINES (in minutes)
60+
# PRIORITY SLA DEADLINES (in minutes) — kept for backwards compatibility
6161
# ============================================================================
6262

6363
PRIORITY_SLA_MINUTES: dict[TicketPriority, int] = {
@@ -68,6 +68,27 @@ class WorkLogType(str, Enum):
6868
}
6969

7070

71+
# ============================================================================
72+
# SLA BREACH THRESHOLDS (in hours) — used for breach status calculations
73+
# Aligns with ITSM standard expectations and the frontend dashboard
74+
# ============================================================================
75+
76+
SLA_THRESHOLD_HOURS: dict[TicketPriority, float] = {
77+
TicketPriority.CRITICAL: 4.0,
78+
TicketPriority.HIGH: 24.0,
79+
TicketPriority.MEDIUM: 72.0,
80+
TicketPriority.LOW: 120.0,
81+
}
82+
83+
84+
class SlaBreachStatus(str, Enum):
85+
"""SLA breach status for a ticket."""
86+
BREACHED = "breached" # age > threshold
87+
AT_RISK = "at_risk" # age > 75% of threshold
88+
OK = "ok" # age <= 75% of threshold
89+
UNKNOWN = "unknown" # cannot determine
90+
91+
7192
# ============================================================================
7293
# WORKLOG MODEL
7394
# ============================================================================
@@ -377,6 +398,141 @@ def build_reminder_candidate(
377398
)
378399

379400

401+
# ============================================================================
402+
# SLA BREACH MODELS — for breach-status reporting
403+
# ============================================================================
404+
405+
class TicketSlaInfo(BaseModel):
406+
"""SLA breach information for a single ticket."""
407+
ticket_id: str = Field(..., description="Incident ID or UUID of the ticket")
408+
priority: str = Field(..., description="Ticket priority (critical/high/medium/low)")
409+
urgency: Optional[str] = Field(None, description="Urgency level")
410+
assigned_group: Optional[str] = Field(None, description="Responsible support team")
411+
reported_date: str = Field(..., description="Ticket creation date (ISO format)")
412+
age_hours: float = Field(..., description="Hours elapsed since creation (1 decimal)")
413+
sla_threshold_hours: float = Field(..., description="SLA threshold in hours for this priority")
414+
breach_status: SlaBreachStatus = Field(..., description="Current SLA breach status")
415+
416+
417+
class SlaBreachReport(BaseModel):
418+
"""Aggregated SLA breach report."""
419+
reference_timestamp: str = Field(..., description="Reference time used for age calculation")
420+
total_breached: int = Field(..., ge=0)
421+
total_at_risk: int = Field(..., ge=0)
422+
tickets: list[TicketSlaInfo] = Field(default_factory=list)
423+
424+
425+
# ============================================================================
426+
# SLA BREACH CALCULATIONS — Pure functions
427+
# ============================================================================
428+
429+
def get_sla_threshold_hours(priority: TicketPriority) -> float:
430+
"""Return the SLA threshold in hours for a given priority."""
431+
return SLA_THRESHOLD_HOURS.get(priority, SLA_THRESHOLD_HOURS[TicketPriority.LOW])
432+
433+
434+
def calculate_ticket_sla_info(
435+
ticket: "Ticket",
436+
reference_time: Optional[datetime] = None,
437+
) -> TicketSlaInfo:
438+
"""
439+
Compute SLA breach information for a single ticket.
440+
441+
Args:
442+
ticket: The ticket to evaluate.
443+
reference_time: Anchor for age calculation. When None, uses the current time.
444+
Callers managing historical datasets should pass the max date in the
445+
dataset so results are deterministic.
446+
447+
Returns:
448+
TicketSlaInfo with pre-computed age_hours and breach_status.
449+
"""
450+
if reference_time is None:
451+
reference_time = datetime.now(ticket.created_at.tzinfo)
452+
453+
delta = reference_time - ticket.created_at
454+
age_hours = round(delta.total_seconds() / 3600, 1)
455+
456+
threshold = get_sla_threshold_hours(ticket.priority)
457+
458+
if age_hours > threshold:
459+
status = SlaBreachStatus.BREACHED
460+
elif age_hours > threshold * 0.75:
461+
status = SlaBreachStatus.AT_RISK
462+
else:
463+
status = SlaBreachStatus.OK
464+
465+
ticket_id = ticket.incident_id or str(ticket.id)
466+
467+
return TicketSlaInfo(
468+
ticket_id=ticket_id,
469+
priority=ticket.priority.value,
470+
urgency=ticket.urgency,
471+
assigned_group=ticket.assigned_group,
472+
reported_date=ticket.created_at.isoformat(),
473+
age_hours=age_hours,
474+
sla_threshold_hours=threshold,
475+
breach_status=status,
476+
)
477+
478+
479+
def get_sla_breach_report(
480+
tickets: "list[Ticket]",
481+
reference_time: Optional[datetime] = None,
482+
include_ok: bool = False,
483+
) -> SlaBreachReport:
484+
"""
485+
Build a sorted, grouped SLA breach report from a ticket list.
486+
487+
Grouping order: breached first, then at_risk, then ok (if include_ok).
488+
Within each group, sorted by age_hours descending.
489+
490+
Args:
491+
tickets: Tickets to evaluate.
492+
reference_time: Anchor timestamp. Uses max created_at in the list when None.
493+
include_ok: Whether to include non-breached, non-at-risk tickets.
494+
495+
Returns:
496+
SlaBreachReport with grouped/sorted TicketSlaInfo entries.
497+
"""
498+
if not tickets:
499+
ref_str = (reference_time or datetime.now()).isoformat()
500+
return SlaBreachReport(reference_timestamp=ref_str, total_breached=0, total_at_risk=0)
501+
502+
if reference_time is None:
503+
reference_time = max(t.created_at for t in tickets)
504+
505+
infos = [calculate_ticket_sla_info(t, reference_time) for t in tickets]
506+
507+
group_order = {
508+
SlaBreachStatus.BREACHED: 0,
509+
SlaBreachStatus.AT_RISK: 1,
510+
SlaBreachStatus.OK: 2,
511+
SlaBreachStatus.UNKNOWN: 3,
512+
}
513+
514+
filtered = [
515+
i for i in infos
516+
if i.breach_status in (SlaBreachStatus.BREACHED, SlaBreachStatus.AT_RISK)
517+
or (include_ok and i.breach_status == SlaBreachStatus.OK)
518+
]
519+
520+
sorted_infos = sorted(
521+
filtered,
522+
key=lambda i: (group_order[i.breach_status], -i.age_hours),
523+
)
524+
525+
total_breached = sum(1 for i in sorted_infos if i.breach_status == SlaBreachStatus.BREACHED)
526+
total_at_risk = sum(1 for i in sorted_infos if i.breach_status == SlaBreachStatus.AT_RISK)
527+
528+
return SlaBreachReport(
529+
reference_timestamp=reference_time.isoformat(),
530+
total_breached=total_breached,
531+
total_at_risk=total_at_risk,
532+
tickets=sorted_infos,
533+
)
534+
535+
380536
# ============================================================================
381537
# EXPORTS
382538
# ============================================================================
@@ -387,8 +543,10 @@ def build_reminder_candidate(
387543
"TicketPriority",
388544
"ModificationStatus",
389545
"WorkLogType",
546+
"SlaBreachStatus",
390547
# Constants
391548
"PRIORITY_SLA_MINUTES",
549+
"SLA_THRESHOLD_HOURS",
392550
# Models
393551
"Ticket",
394552
"TicketWithDetails",
@@ -403,6 +561,9 @@ def build_reminder_candidate(
403561
"ModificationCreate",
404562
"ModificationReview",
405563
"OverlayMetadata",
564+
# SLA breach models
565+
"TicketSlaInfo",
566+
"SlaBreachReport",
406567
# Reminder models
407568
"ReminderCandidate",
408569
"ReminderRequest",
@@ -414,4 +575,8 @@ def build_reminder_candidate(
414575
"is_assigned_without_assignee",
415576
"count_reminders_in_worklogs",
416577
"build_reminder_candidate",
578+
# SLA breach calculations
579+
"get_sla_threshold_hours",
580+
"calculate_ticket_sla_info",
581+
"get_sla_breach_report",
417582
]

frontend/src/features/usecase-demo/demoDefinitions.js

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,18 @@ Für schnelle Ausführung:
1515
Liefere nur eine kurze, handlungsorientierte Zusammenfassung mit Prioritäten und nächstem Schritt.
1616
Nutze ausschließlich CSV-Daten und nenne die verwendeten Ticket-IDs in Fließtext.`;
1717

18-
const SLA_BREACH_DEFAULT_PROMPT = `Find all tickets where the "assignee" field is empty (no individual person assigned — note: Status "Assigned" means assigned to a group, NOT to a person. A ticket can have Status="Assigned" and still have no individual assignee).
18+
const SLA_BREACH_DEFAULT_PROMPT = `Use the csv_sla_breach_tickets tool to retrieve all unassigned tickets at SLA risk.
1919
20-
IMPORTANT: When calling csv_list_tickets, use the "fields" parameter to request ONLY the fields you need: "id,summary,priority,urgency,assignee,assigned_group,created_at". Also use has_assignee=false to filter server-side. This avoids fetching unnecessary data.
21-
Set limit=100 for initial retrieval and keep this compact field set unless a specific ticket needs deep detail.
22-
Do not use semantic/text-heavy analysis for this use case; numeric/status/date fields are sufficient.
23-
Do not include notes/resolution by default; fetch them only for a small number of tickets if strictly required.
20+
Call it with default parameters (unassigned_only=true, include_ok=false). The tool returns a pre-computed SlaBreachReport with:
21+
- reference_timestamp: the anchor date used for age calculations (max date in the dataset)
22+
- total_breached / total_at_risk: summary counts
23+
- tickets: list of TicketSlaInfo objects already grouped (breached → at_risk) and sorted by age_hours descending
2424
25-
Check these tickets against priority-based SLA thresholds measuring time since created_at (the reported date).
25+
Output the tickets array as a JSON block with these fields per row:
26+
ticket_id, priority, urgency, assigned_group, reported_date, age_hours, sla_threshold_hours, breach_status
2627
27-
IMPORTANT: For the reference timestamp ("now"), do NOT use the current system time. Instead, find the most recent date across all date fields in the CSV data and use that as "now". This ensures meaningful age calculations for demo/historical data.
28-
29-
SLA thresholds by priority:
30-
- Critical (1): 4 hours
31-
- High (2): 24 hours (1 day)
32-
- Medium (3): 72 hours (3 days)
33-
- Low (4): 120 hours (5 days)
34-
35-
For each matching ticket, compute:
36-
- age_hours: hours elapsed from reported_date to the reference timestamp
37-
- sla_threshold_hours: the SLA threshold for its priority
38-
- breach_status: "breached" if age_hours > sla_threshold_hours, "at risk" if age_hours > 0.75 * sla_threshold_hours, otherwise "ok"
39-
40-
Return ALL tickets with breach_status "breached" or "at risk". Group them by breach_status (all "breached" first, then all "at risk"), and within each group sort by age_hours descending.
41-
42-
Output a JSON array of objects with these fields: ticket_id, priority, urgency, assigned_group (the team responsible), reported_date, age_hours (rounded to 1 decimal), sla_threshold_hours, breach_status.
4328
After the JSON block, provide a short markdown summary that:
44-
1. States the reference timestamp used
29+
1. States the reference_timestamp used
4530
2. Groups ticket counts by breach_status and assigned_group
4631
3. Recommends actions for the most critical breaches`;
4732

0 commit comments

Comments
 (0)