Skip to content

Commit d1a653a

Browse files
authored
Optimize agent runtime and SLA demo flow (#15)
* feat: Enhance SLA Breach Risk functionality and UI integration - Increased max_length for agent prompt to 5000 - Added fields parameter to list and search tickets for selective data retrieval - Updated timeout for usecase demo agent to 300 seconds - Introduced SLA Breach Risk demo with detailed prompt and ticket analysis - Added E2E tests for SLA Breach Risk demo page * feat: add incident_id field to ticket model and related components - Added incident_id to the ticket mapping in app.py. - Updated csv_data.py to include incident_id when converting CSV rows to tickets. - Modified operations.py to define incident_id as a CSV ticket field. - Enhanced the Ticket model in tickets.py to include incident_id. - Updated usecase_demo.py to accommodate changes in ticket structure. - Modified CSVTicketTable.jsx to display incident_id in the ticket table. - Updated TicketList.jsx to filter and display incident_id in the ticket list. - Enhanced TicketsWithoutAnAssignee.jsx to include incident_id in ticket operations. - Updated UsecaseDemoPage.jsx to pass matchingTickets to the render function. - Enhanced demoDefinitions.js to improve prompts for use case demos. - Added SLA Breach Overview result view in resultViews.jsx to visualize SLA status of tickets. Signed-off-by: Andre Bossard <anbossar@microsoft.com> * refactor: clean up import statements across multiple components Signed-off-by: Andre Bossard <anbossar@microsoft.com> * refactor: standardize import statement formatting in resultViews.jsx Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: add SLA breach reporting functionality and related API endpoints Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: implement SLA breach report retrieval for unassigned tickets Signed-off-by: Andre Bossard <anbossar@microsoft.com> --------- Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent 405f359 commit d1a653a

15 files changed

Lines changed: 1185 additions & 268 deletions

File tree

backend/agents.py

Lines changed: 267 additions & 74 deletions
Large diffs are not rendered by default.

backend/app.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
# CSV ticket service
3838
from csv_data import Ticket, get_csv_ticket_service
39-
from usecase_demo import UsecaseDemoRunCreate, usecase_demo_run_service
4039

4140
# FastMCP client for direct ticket MCP calls (no AI)
4241
from fastmcp import Client as MCPClient
@@ -51,6 +50,7 @@
5150
op_update_task,
5251
task_service,
5352
)
53+
from usecase_demo import UsecaseDemoRunCreate, usecase_demo_run_service
5454

5555
# Ticket MCP server URL (same as in agents.py)
5656
TICKET_MCP_SERVER_URL = "https://yodrrscbpxqnslgugwow.supabase.co/functions/v1/mcp/a7f2b8c4-d3e9-4f1a-b5c6-e8d9f0123456"
@@ -375,6 +375,7 @@ def _map_mcp_ticket_to_frontend(mcp_ticket: dict) -> dict:
375375

376376
return {
377377
"id": str(mcp_ticket.get("id", "")),
378+
"incident_id": mcp_ticket.get("incident_id"),
378379
"title": mcp_ticket.get("summary", ""),
379380
"description": mcp_ticket.get("description", ""),
380381
"status": status,
@@ -622,6 +623,28 @@ async def get_csv_ticket_stats():
622623
})
623624

624625

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+
625648
@app.route("/api/health", methods=["GET"])
626649
async def health_check():
627650
"""Health check endpoint."""

backend/csv_data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def csv_row_to_ticket(row: CSVTicketRow) -> Ticket:
290290

291291
return Ticket(
292292
id=ticket_id,
293+
incident_id=row.incident_id or row.entry_id or None,
293294
summary=row.summary or "No summary",
294295
description=row.notes or row.summary or "No description",
295296
status=map_status(row.status or row.status_ppl),

backend/operations.py

Lines changed: 47 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()
@@ -21,6 +27,7 @@
2127

2228

2329
CSV_TICKET_FIELDS = [
30+
{"name": "incident_id", "label": "Incident ID", "type": "string"},
2431
{"name": "id", "label": "ID", "type": "uuid"},
2532
{"name": "summary", "label": "Summary", "type": "string"},
2633
{"name": "status", "label": "Status", "type": "enum"},
@@ -271,6 +278,44 @@ async def op_csv_ticket_fields() -> list[dict[str, str]]:
271278
return CSV_TICKET_FIELDS
272279

273280

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+
274319
# Export shared services for callers (REST app, CLI tools, etc.)
275320
task_service = _task_service
276321
csv_ticket_service = _csv_service
@@ -289,5 +334,6 @@ async def op_csv_ticket_fields() -> list[dict[str, str]]:
289334
"op_csv_search_tickets",
290335
"op_csv_ticket_stats",
291336
"op_csv_ticket_fields",
337+
"op_csv_sla_breach_tickets",
292338
"CSV_TICKET_FIELDS",
293339
]

backend/tickets.py

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
from pydantic import BaseModel, Field
1919

20-
2120
# ============================================================================
2221
# ENUMS - Status and Priority types
2322
# ============================================================================
@@ -58,7 +57,7 @@ class WorkLogType(str, Enum):
5857

5958

6059
# ============================================================================
61-
# PRIORITY SLA DEADLINES (in minutes)
60+
# PRIORITY SLA DEADLINES (in minutes) — kept for backwards compatibility
6261
# ============================================================================
6362

6463
PRIORITY_SLA_MINUTES: dict[TicketPriority, int] = {
@@ -69,6 +68,27 @@ class WorkLogType(str, Enum):
6968
}
7069

7170

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+
7292
# ============================================================================
7393
# WORKLOG MODEL
7494
# ============================================================================
@@ -159,6 +179,7 @@ class Ticket(BaseModel):
159179
"""
160180
# Core identifiers
161181
id: UUID = Field(..., description="Unique ticket identifier")
182+
incident_id: Optional[str] = Field(None, description="Original incident ID (INC...)")
162183

163184
# Summary and description
164185
summary: str = Field(..., max_length=500, description="Short issue summary")
@@ -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
]

backend/usecase_demo.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from agents import AgentRequest, agent_service
2424

2525
USECASE_DEMO_AGENT_TIMEOUT_SECONDS = float(
26-
os.getenv("USECASE_DEMO_AGENT_TIMEOUT_SECONDS", "120")
26+
os.getenv("USECASE_DEMO_AGENT_TIMEOUT_SECONDS", "300")
2727
)
2828

2929

@@ -191,14 +191,13 @@ async def _execute_run(self, run_id: str) -> None:
191191
# Enforce a predictable output block for table rendering.
192192
structured_prompt = (
193193
f"{run.prompt}\n\n"
194-
"Zusatzformat:\n"
195-
"- Gib zuerst eine kurze Zusammenfassung.\n"
196-
"- Füge danach einen JSON-Codeblock mit `rows` hinzu.\n"
197-
"- JSON-Schema:\n"
198-
" {\"rows\": [{\"menu_point\": \"...\", \"project_name\": \"...\", "
199-
"\"summary\": \"...\", \"agent_prompt\": \"...\", \"ticket_ids\": \"...\", "
200-
"\"csv_evidence\": \"...\"}]}\n"
201-
"- Falls keine sinnvollen Zeilen existieren, gib `{\"rows\": []}` zurück."
194+
"Antwortformat:\n"
195+
"- Führe die Anfrage mit möglichst wenigen Tool-Aufrufen aus.\n"
196+
"- Nutze kompakte fields und sinnvolle limits.\n"
197+
"- Fordere notes/resolution nur bei explizitem Bedarf an.\n"
198+
"- Gib einen JSON-Codeblock mit {\"rows\": [...]} zurück.\n"
199+
"- Falls keine sinnvollen Zeilen existieren, gib {\"rows\": []} zurück.\n"
200+
"- Optional danach: kurze Zusammenfassung in 2-4 Stichpunkten."
202201
)
203202

204203
try:

0 commit comments

Comments
 (0)