Spaces:
Sleeping
Sleeping
Commit Β·
03d30a6
1
Parent(s): 05d8492
better workflows
Browse files- baseline_scores.json +4 -4
- inference.py +26 -6
- server/apps/jira.py +34 -254
- server/apps/salesforce.py +34 -8
- server/apps/workday.py +18 -5
- server/apps/zendesk.py +6 -261
- server/data_generator.py +31 -24
- server/environment.py +13 -4
- server/workflow_engine.py +73 -18
baseline_scores.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
{
|
| 2 |
"scores": {
|
| 3 |
-
"workflow_A": 0.
|
| 4 |
-
"workflow_B": 0.
|
| 5 |
-
"workflow_C": 0.
|
| 6 |
},
|
| 7 |
-
"average": 0.
|
| 8 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"scores": {
|
| 3 |
+
"workflow_A": 0.697,
|
| 4 |
+
"workflow_B": 0.744,
|
| 5 |
+
"workflow_C": 0.722
|
| 6 |
},
|
| 7 |
+
"average": 0.721
|
| 8 |
}
|
inference.py
CHANGED
|
@@ -76,18 +76,38 @@ CRITICAL RULES:
|
|
| 76 |
1. Read schema_hints FIRST β if "jira.priority" β "severity", use "severity" not "priority" in args.
|
| 77 |
2. Complete ALL pending_steps in order.
|
| 78 |
3. Do not repeat a successful action.
|
| 79 |
-
4.
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
|
| 83 |
Example actions:
|
|
|
|
| 84 |
{"app": "zendesk", "operation": "list_tickets", "args": {"state": "new"}}
|
| 85 |
{"app": "zendesk", "operation": "acknowledge_ticket", "args": {"ticket_number": "<ticket_number from list_tickets>"}}
|
| 86 |
{"app": "jira", "operation": "create_issue", "args": {"title": "Bug fix for <customer>", "linked_zendesk": "<ticket_number>"}}
|
| 87 |
-
{"
|
| 88 |
-
{"app": "
|
|
|
|
|
|
|
| 89 |
{"app": "workday", "operation": "list_employees", "args": {"status": "pending"}}
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
"""
|
| 92 |
|
| 93 |
WORKFLOW_NAMES = {
|
|
|
|
| 76 |
1. Read schema_hints FIRST β if "jira.priority" β "severity", use "severity" not "priority" in args.
|
| 77 |
2. Complete ALL pending_steps in order.
|
| 78 |
3. Do not repeat a successful action.
|
| 79 |
+
4. When an operation creates a new resource (e.g. create_issue returns issue_id "JIRA-051"),
|
| 80 |
+
use THAT returned ID for all subsequent operations on that resource β not a pre-existing ID.
|
| 81 |
+
5. If an operation fails, read the message carefully and adapt.
|
| 82 |
+
6. Use list_* operations to discover record IDs when needed β never assume an ID.
|
| 83 |
+
7. Stop when pending_steps is empty or done=true.
|
| 84 |
|
| 85 |
Example actions:
|
| 86 |
+
# Workflow A (bug fix) β discover then chain ticket_id and returned issue_id:
|
| 87 |
{"app": "zendesk", "operation": "list_tickets", "args": {"state": "new"}}
|
| 88 |
{"app": "zendesk", "operation": "acknowledge_ticket", "args": {"ticket_number": "<ticket_number from list_tickets>"}}
|
| 89 |
{"app": "jira", "operation": "create_issue", "args": {"title": "Bug fix for <customer>", "linked_zendesk": "<ticket_number>"}}
|
| 90 |
+
# β create_issue returns {"issue_id": "JIRA-051", ...} β use JIRA-051 for assign_owner below
|
| 91 |
+
{"app": "jira", "operation": "assign_owner", "args": {"issue_id": "<issue_id from create_issue>", "assignee": "<engineer>"}}
|
| 92 |
+
|
| 93 |
+
# Workflow B (onboarding) β find the pending employee, then thread employee_id + territory:
|
| 94 |
{"app": "workday", "operation": "list_employees", "args": {"status": "pending"}}
|
| 95 |
+
# β returns exactly one record. Capture employee_id (e.g. "EMP-NEW-001") AND territory (e.g. "west").
|
| 96 |
+
{"app": "workday", "operation": "create_onboarding_task", "args": {"employee_id": "<employee_id from list>"}}
|
| 97 |
+
{"app": "workday", "operation": "provision_access", "args": {"employee_id": "<employee_id from list>", "app_name": "jira"}}
|
| 98 |
+
# SF: assign that employee as owner of an account in THEIR territory:
|
| 99 |
+
{"app": "salesforce", "operation": "list_accounts", "args": {"territory": "<territory from list_employees>"}}
|
| 100 |
+
{"app": "salesforce", "operation": "assign_account_owner", "args": {"account_id": "<account_id from list>", "owner": "<employee_id from list>"}}
|
| 101 |
+
# Jira: assign an open issue to the new hire's employee_id:
|
| 102 |
+
{"app": "jira", "operation": "list_issues", "args": {"status": "open"}}
|
| 103 |
+
{"app": "jira", "operation": "assign_owner", "args": {"issue_id": "<issue_id from list>", "assignee": "<employee_id from list_employees>"}}
|
| 104 |
+
|
| 105 |
+
# Workflow C (churn) β discover the at-risk account, then scope all queries to it:
|
| 106 |
+
{"app": "salesforce", "operation": "list_accounts", "args": {"health": "red"}}
|
| 107 |
+
{"app": "salesforce", "operation": "flag_churn_risk", "args": {"account_id": "<account_id from list>"}}
|
| 108 |
+
{"app": "zendesk", "operation": "get_ticket", "args": {"customer_id": "<account_id from list>"}}
|
| 109 |
+
{"app": "jira", "operation": "list_issues", "args": {"customer_id": "<account_id from list>"}}
|
| 110 |
+
{"app": "salesforce", "operation": "assign_account_owner", "args": {"account_id": "<account_id from list>", "owner": "<engineer>"}}
|
| 111 |
"""
|
| 112 |
|
| 113 |
WORKFLOW_NAMES = {
|
server/apps/jira.py
CHANGED
|
@@ -19,7 +19,7 @@ class JiraApp(BaseApp):
|
|
| 19 |
# Workflow completion state tracking
|
| 20 |
self._linked_issues: set = set() # issue_ids linked to a Zendesk ticket
|
| 21 |
self._assigned_issues: set = set() # issue_ids with a non-null assignee
|
| 22 |
-
self.
|
| 23 |
|
| 24 |
# ------------------------------------------------------------------
|
| 25 |
# BaseApp interface
|
|
@@ -29,7 +29,7 @@ class JiraApp(BaseApp):
|
|
| 29 |
self._records = {r["issue_id"]: r for r in records}
|
| 30 |
self._linked_issues.clear()
|
| 31 |
self._assigned_issues.clear()
|
| 32 |
-
self.
|
| 33 |
# Seed state from loaded data
|
| 34 |
for issue_id, rec in self._records.items():
|
| 35 |
if rec.get("assignee"):
|
|
@@ -78,12 +78,33 @@ class JiraApp(BaseApp):
|
|
| 78 |
return len(self._linked_issues) > 0
|
| 79 |
|
| 80 |
def issue_assigned(self) -> bool:
|
| 81 |
-
"""True once
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
# ------------------------------------------------------------------
|
| 89 |
# Operations
|
|
@@ -229,7 +250,10 @@ class JiraApp(BaseApp):
|
|
| 229 |
|
| 230 |
def _op_list_issues(self, status: str = "open", customer_id: Optional[str] = None,
|
| 231 |
limit: int = 10) -> Dict:
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
| 233 |
matching = [
|
| 234 |
r for r in self._records.values()
|
| 235 |
if (status == "all" or r.get("status") == status)
|
|
@@ -244,248 +268,4 @@ class JiraApp(BaseApp):
|
|
| 244 |
for r in drifted]
|
| 245 |
return {"success": True, "data": compact,
|
| 246 |
"message": f"Found {len(compact)} {status} issues"
|
| 247 |
-
+ (f" for {customer_id}" if customer_id else "")}
|
| 248 |
-
# """Jira-like app β engineering ticket management."""
|
| 249 |
-
|
| 250 |
-
# from typing import Dict, List, Optional
|
| 251 |
-
# from server.apps.base_app import BaseApp
|
| 252 |
-
# from server.schema_drift import SchemaDriftEngine
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
# class JiraApp(BaseApp):
|
| 256 |
-
# APP_NAME = "jira"
|
| 257 |
-
|
| 258 |
-
# OPERATIONS = [
|
| 259 |
-
# "get_issue", "create_issue", "update_status", "set_priority",
|
| 260 |
-
# "assign_owner", "add_label", "link_zendesk_ticket", "close_issue", "list_issues",
|
| 261 |
-
# ]
|
| 262 |
-
|
| 263 |
-
# def __init__(self, drift: SchemaDriftEngine):
|
| 264 |
-
# super().__init__(drift)
|
| 265 |
-
# self._records: Dict[str, Dict] = {}
|
| 266 |
-
# # Workflow completion state tracking
|
| 267 |
-
# self._linked_issues: set = set() # issue_ids linked to a Zendesk ticket
|
| 268 |
-
# self._assigned_issues: set = set() # issue_ids with a non-null assignee
|
| 269 |
-
# self._bugs_checked: bool = False # list_issues was called (Workflow C)
|
| 270 |
-
|
| 271 |
-
# # ------------------------------------------------------------------
|
| 272 |
-
# # BaseApp interface
|
| 273 |
-
# # ------------------------------------------------------------------
|
| 274 |
-
|
| 275 |
-
# def initialize(self, records: List[Dict]) -> None:
|
| 276 |
-
# self._records = {r["issue_id"]: r for r in records}
|
| 277 |
-
# self._linked_issues.clear()
|
| 278 |
-
# self._assigned_issues.clear()
|
| 279 |
-
# self._bugs_checked = False
|
| 280 |
-
# # Seed state from loaded data
|
| 281 |
-
# for issue_id, rec in self._records.items():
|
| 282 |
-
# if rec.get("assignee"):
|
| 283 |
-
# self._assigned_issues.add(issue_id)
|
| 284 |
-
# if rec.get("linked_zendesk"):
|
| 285 |
-
# self._linked_issues.add(issue_id)
|
| 286 |
-
|
| 287 |
-
# def execute(self, operation: str, args: Dict) -> Dict:
|
| 288 |
-
# method = getattr(self, f"_op_{operation}", None)
|
| 289 |
-
# if method is None:
|
| 290 |
-
# return {
|
| 291 |
-
# "success": False,
|
| 292 |
-
# "message": f"Unknown operation '{operation}'. Available: {', '.join(self.OPERATIONS)}",
|
| 293 |
-
# }
|
| 294 |
-
# try:
|
| 295 |
-
# return method(**args)
|
| 296 |
-
# except TypeError as exc:
|
| 297 |
-
# return {"success": False, "message": f"Bad args for '{operation}': {exc}"}
|
| 298 |
-
|
| 299 |
-
# def get_state_view(self, max_rows: int = 5) -> str:
|
| 300 |
-
# open_issues = [r for r in self._records.values()
|
| 301 |
-
# if r.get("status") not in ("closed",)][:max_rows]
|
| 302 |
-
# if not open_issues:
|
| 303 |
-
# return "No open issues."
|
| 304 |
-
# lines = []
|
| 305 |
-
# for rec in open_issues:
|
| 306 |
-
# view = self._to_agent_view(rec)
|
| 307 |
-
# keep = ["issue_id", "title",
|
| 308 |
-
# "priority", "severity", "urgency_level",
|
| 309 |
-
# "assignee", "owner", "assigned_to",
|
| 310 |
-
# "status", "state", "current_state",
|
| 311 |
-
# "customer_id", "linked_zendesk"]
|
| 312 |
-
# compact = {k: v for k, v in view.items() if k in keep and v is not None}
|
| 313 |
-
# lines.append(str(compact))
|
| 314 |
-
# return "\n".join(lines)
|
| 315 |
-
|
| 316 |
-
# def count_open_items(self) -> int:
|
| 317 |
-
# return sum(1 for r in self._records.values() if r.get("status") != "closed")
|
| 318 |
-
|
| 319 |
-
# # ------------------------------------------------------------------
|
| 320 |
-
# # Workflow completion state checks
|
| 321 |
-
# # ------------------------------------------------------------------
|
| 322 |
-
|
| 323 |
-
# def has_linked_issue(self) -> bool:
|
| 324 |
-
# """True once any issue is linked to a Zendesk ticket (Workflow A step A2)."""
|
| 325 |
-
# return len(self._linked_issues) > 0
|
| 326 |
-
|
| 327 |
-
# def issue_assigned(self) -> bool:
|
| 328 |
-
# """True once JIRA-001 (primary bug) has an assignee (Workflow A step A4)."""
|
| 329 |
-
# return bool(self._records.get("JIRA-001", {}).get("assignee"))
|
| 330 |
-
|
| 331 |
-
# def bugs_checked(self) -> bool:
|
| 332 |
-
# """True once list_issues has been called (Workflow C step C3)."""
|
| 333 |
-
# return self._bugs_checked
|
| 334 |
-
|
| 335 |
-
# # ------------------------------------------------------------------
|
| 336 |
-
# # Operations
|
| 337 |
-
# # ------------------------------------------------------------------
|
| 338 |
-
|
| 339 |
-
# def _op_get_issue(self, issue_id: str) -> Dict:
|
| 340 |
-
# rec = self._records.get(issue_id)
|
| 341 |
-
# if not rec:
|
| 342 |
-
# return {"success": False, "message": f"Issue {issue_id} not found. Use list_issues to browse."}
|
| 343 |
-
# return {"success": True, "data": self._to_agent_view(rec),
|
| 344 |
-
# "message": f"Retrieved {issue_id}"}
|
| 345 |
-
|
| 346 |
-
# def _op_create_issue(self, title: str, **kwargs) -> Dict:
|
| 347 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 348 |
-
# if schema_error:
|
| 349 |
-
# return {
|
| 350 |
-
# "success": False,
|
| 351 |
-
# "schema_error": schema_error,
|
| 352 |
-
# "message": (f"Schema error: field '{schema_error}' is not in the current schema. "
|
| 353 |
-
# f"Check schema_hints for the correct field name."),
|
| 354 |
-
# }
|
| 355 |
-
|
| 356 |
-
# issue_id = f"JIRA-{len(self._records) + 1:03d}"
|
| 357 |
-
# # Accept both canonical and drifted names for priority / assignee
|
| 358 |
-
# priority = (kwargs.get("priority") or kwargs.get("severity")
|
| 359 |
-
# or kwargs.get("urgency_level", "p2"))
|
| 360 |
-
# linked = kwargs.get("linked_zendesk") or kwargs.get("zendesk_ticket")
|
| 361 |
-
|
| 362 |
-
# rec = {
|
| 363 |
-
# "issue_id": issue_id,
|
| 364 |
-
# "title": title,
|
| 365 |
-
# "priority": priority,
|
| 366 |
-
# "assignee": kwargs.get("assignee") or kwargs.get("owner") or kwargs.get("assigned_to"),
|
| 367 |
-
# "status": "open",
|
| 368 |
-
# "reporter": kwargs.get("reporter", "agent"),
|
| 369 |
-
# "customer_id": kwargs.get("customer_id"),
|
| 370 |
-
# "linked_zendesk": linked,
|
| 371 |
-
# "labels": [],
|
| 372 |
-
# "created_at": "2026-04-21T09:00:00",
|
| 373 |
-
# }
|
| 374 |
-
# self._records[issue_id] = rec
|
| 375 |
-
|
| 376 |
-
# if linked:
|
| 377 |
-
# self._linked_issues.add(issue_id)
|
| 378 |
-
# if rec["assignee"]:
|
| 379 |
-
# self._assigned_issues.add(issue_id)
|
| 380 |
-
|
| 381 |
-
# return {
|
| 382 |
-
# "success": True,
|
| 383 |
-
# "data": {"issue_id": issue_id},
|
| 384 |
-
# "schema_adapted": schema_adapted,
|
| 385 |
-
# "message": f"Created {issue_id}: '{title}'"
|
| 386 |
-
# + (f" linked to {linked}" if linked else ""),
|
| 387 |
-
# }
|
| 388 |
-
|
| 389 |
-
# def _op_update_status(self, issue_id: str, **kwargs) -> Dict:
|
| 390 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 391 |
-
# if schema_error:
|
| 392 |
-
# return {"success": False, "schema_error": schema_error,
|
| 393 |
-
# "message": f"Schema error: use current field name, not '{schema_error}'"}
|
| 394 |
-
|
| 395 |
-
# rec = self._records.get(issue_id)
|
| 396 |
-
# if not rec:
|
| 397 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 398 |
-
|
| 399 |
-
# new_status = (kwargs.get("status") or kwargs.get("state")
|
| 400 |
-
# or kwargs.get("current_state"))
|
| 401 |
-
# if not new_status:
|
| 402 |
-
# return {"success": False, "message": "Provide status/state/current_state value"}
|
| 403 |
-
|
| 404 |
-
# rec["status"] = new_status
|
| 405 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 406 |
-
# "message": f"{issue_id} status β '{new_status}'"}
|
| 407 |
-
|
| 408 |
-
# def _op_set_priority(self, issue_id: str, **kwargs) -> Dict:
|
| 409 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 410 |
-
# if schema_error:
|
| 411 |
-
# return {"success": False, "schema_error": schema_error,
|
| 412 |
-
# "message": f"Schema error: '{schema_error}' is a stale field name"}
|
| 413 |
-
|
| 414 |
-
# rec = self._records.get(issue_id)
|
| 415 |
-
# if not rec:
|
| 416 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 417 |
-
|
| 418 |
-
# new_priority = (kwargs.get("priority") or kwargs.get("severity")
|
| 419 |
-
# or kwargs.get("urgency_level"))
|
| 420 |
-
# if not new_priority:
|
| 421 |
-
# return {"success": False,
|
| 422 |
-
# "message": "Provide priority / severity / urgency_level value"}
|
| 423 |
-
|
| 424 |
-
# rec["priority"] = new_priority
|
| 425 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 426 |
-
# "message": f"{issue_id} priority β '{new_priority}'"}
|
| 427 |
-
|
| 428 |
-
# def _op_assign_owner(self, issue_id: str, **kwargs) -> Dict:
|
| 429 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 430 |
-
# if schema_error:
|
| 431 |
-
# hint = self._drift.translate_field("assignee", self.APP_NAME)
|
| 432 |
-
# return {"success": False, "schema_error": schema_error,
|
| 433 |
-
# "message": f"Schema error: use '{hint}' instead of '{schema_error}'"}
|
| 434 |
-
|
| 435 |
-
# rec = self._records.get(issue_id)
|
| 436 |
-
# if not rec:
|
| 437 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 438 |
-
|
| 439 |
-
# assignee = (kwargs.get("assignee") or kwargs.get("owner")
|
| 440 |
-
# or kwargs.get("assigned_to"))
|
| 441 |
-
# if not assignee:
|
| 442 |
-
# correct_field = self._drift.translate_field("assignee", self.APP_NAME)
|
| 443 |
-
# return {"success": False,
|
| 444 |
-
# "message": f"Missing assignee field. Use '{correct_field}' as the arg key for this episode."}
|
| 445 |
-
|
| 446 |
-
# rec["assignee"] = assignee
|
| 447 |
-
# self._assigned_issues.add(issue_id)
|
| 448 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 449 |
-
# "message": f"{issue_id} assigned to '{assignee}'"}
|
| 450 |
-
|
| 451 |
-
# def _op_add_label(self, issue_id: str, label: str) -> Dict:
|
| 452 |
-
# rec = self._records.get(issue_id)
|
| 453 |
-
# if not rec:
|
| 454 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 455 |
-
# rec.setdefault("labels", []).append(label)
|
| 456 |
-
# return {"success": True, "message": f"Added label '{label}' to {issue_id}"}
|
| 457 |
-
|
| 458 |
-
# def _op_link_zendesk_ticket(self, issue_id: str, zendesk_ticket_number: str) -> Dict:
|
| 459 |
-
# rec = self._records.get(issue_id)
|
| 460 |
-
# if not rec:
|
| 461 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 462 |
-
# rec["linked_zendesk"] = zendesk_ticket_number
|
| 463 |
-
# self._linked_issues.add(issue_id)
|
| 464 |
-
# return {"success": True,
|
| 465 |
-
# "message": f"Linked {issue_id} β Zendesk {zendesk_ticket_number}"}
|
| 466 |
-
|
| 467 |
-
# def _op_close_issue(self, issue_id: str) -> Dict:
|
| 468 |
-
# rec = self._records.get(issue_id)
|
| 469 |
-
# if not rec:
|
| 470 |
-
# return {"success": False, "message": f"Issue {issue_id} not found"}
|
| 471 |
-
# rec["status"] = "closed"
|
| 472 |
-
# return {"success": True, "message": f"Closed {issue_id}"}
|
| 473 |
-
|
| 474 |
-
# def _op_list_issues(self, status: str = "open", customer_id: Optional[str] = None,
|
| 475 |
-
# limit: int = 10) -> Dict:
|
| 476 |
-
# self._bugs_checked = True
|
| 477 |
-
# matching = [
|
| 478 |
-
# r for r in self._records.values()
|
| 479 |
-
# if (status == "all" or r.get("status") == status)
|
| 480 |
-
# and (customer_id is None or r.get("customer_id") == customer_id)
|
| 481 |
-
# ][:limit]
|
| 482 |
-
# drifted = [self._to_agent_view(r) for r in matching]
|
| 483 |
-
# keep = ["issue_id", "title", "priority", "severity", "urgency_level",
|
| 484 |
-
# "assignee", "owner", "assigned_to",
|
| 485 |
-
# "status", "state", "current_state",
|
| 486 |
-
# "customer_id", "linked_zendesk"]
|
| 487 |
-
# compact = [{k: v for k, v in r.items() if k in keep and v is not None}
|
| 488 |
-
# for r in drifted]
|
| 489 |
-
# return {"success": True, "data": compact,
|
| 490 |
-
# "message": f"Found {len(compact)} {status} issues"
|
| 491 |
-
# + (f" for {customer_id}" if customer_id else "")}
|
|
|
|
| 19 |
# Workflow completion state tracking
|
| 20 |
self._linked_issues: set = set() # issue_ids linked to a Zendesk ticket
|
| 21 |
self._assigned_issues: set = set() # issue_ids with a non-null assignee
|
| 22 |
+
self._bugs_checked_for: set = set() # customer_ids queried via list_issues (Workflow C)
|
| 23 |
|
| 24 |
# ------------------------------------------------------------------
|
| 25 |
# BaseApp interface
|
|
|
|
| 29 |
self._records = {r["issue_id"]: r for r in records}
|
| 30 |
self._linked_issues.clear()
|
| 31 |
self._assigned_issues.clear()
|
| 32 |
+
self._bugs_checked_for.clear()
|
| 33 |
# Seed state from loaded data
|
| 34 |
for issue_id, rec in self._records.items():
|
| 35 |
if rec.get("assignee"):
|
|
|
|
| 78 |
return len(self._linked_issues) > 0
|
| 79 |
|
| 80 |
def issue_assigned(self) -> bool:
|
| 81 |
+
"""True once any linked issue has an assignee (Workflow A step A4).
|
| 82 |
+
Prefers the primary bug; falls back to any linked+assigned issue."""
|
| 83 |
+
# Primary path: the primary bug was linked and assigned
|
| 84 |
+
primary = next(
|
| 85 |
+
(r for r in self._records.values() if r.get("_is_primary_bug")), None
|
| 86 |
+
)
|
| 87 |
+
if primary and primary.get("assignee") and primary["issue_id"] in self._linked_issues:
|
| 88 |
+
return True
|
| 89 |
+
# Fallback: any issue that's both linked to Zendesk and has an assignee
|
| 90 |
+
return any(
|
| 91 |
+
issue_id in self._linked_issues and self._records[issue_id].get("assignee")
|
| 92 |
+
for issue_id in self._linked_issues
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def bugs_checked_for(self, account_id: str) -> bool:
|
| 96 |
+
"""True once list_issues was called WITH customer_id=account_id (Workflow C step C3).
|
| 97 |
+
Tightened from the old free-pass `bugs_checked` flag β the agent must now scope the
|
| 98 |
+
query to the at-risk account specifically, forcing real cross-app data flow from C1."""
|
| 99 |
+
return bool(account_id) and account_id in self._bugs_checked_for
|
| 100 |
+
|
| 101 |
+
def new_hire_assigned_to_issue(self, employee_id: str) -> bool:
|
| 102 |
+
"""True once any Jira issue's assignee equals employee_id (Workflow B step B4).
|
| 103 |
+
Forces the agent to use the new hire's employee_id (discovered in B1) when
|
| 104 |
+
assigning a Jira issue β real Workday β Jira data flow, no free-pass."""
|
| 105 |
+
if not employee_id:
|
| 106 |
+
return False
|
| 107 |
+
return any(r.get("assignee") == employee_id for r in self._records.values())
|
| 108 |
|
| 109 |
# ------------------------------------------------------------------
|
| 110 |
# Operations
|
|
|
|
| 250 |
|
| 251 |
def _op_list_issues(self, status: str = "open", customer_id: Optional[str] = None,
|
| 252 |
limit: int = 10) -> Dict:
|
| 253 |
+
# Track which customer_id was queried β used by bugs_checked_for() (Workflow C C3).
|
| 254 |
+
# Bare list_issues() with no filter no longer satisfies C3.
|
| 255 |
+
if customer_id:
|
| 256 |
+
self._bugs_checked_for.add(customer_id)
|
| 257 |
matching = [
|
| 258 |
r for r in self._records.values()
|
| 259 |
if (status == "all" or r.get("status") == status)
|
|
|
|
| 268 |
for r in drifted]
|
| 269 |
return {"success": True, "data": compact,
|
| 270 |
"message": f"Found {len(compact)} {status} issues"
|
| 271 |
+
+ (f" for {customer_id}" if customer_id else "")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/apps/salesforce.py
CHANGED
|
@@ -65,20 +65,44 @@ class SalesforceApp(BaseApp):
|
|
| 65 |
# ------------------------------------------------------------------
|
| 66 |
|
| 67 |
def account_checked(self) -> bool:
|
| 68 |
-
"""True once get_account was called for
|
| 69 |
-
return
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
def churn_flagged(self) -> bool:
|
| 72 |
-
"""True once flag_churn_risk was called for
|
| 73 |
-
return
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
def team_assigned(self) -> bool:
|
| 76 |
-
"""
|
|
|
|
| 77 |
return any(r.get("_team_assigned") for r in self._records.values())
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def intervention_assigned(self) -> bool:
|
| 80 |
-
"""True once assign_account_owner called on
|
| 81 |
-
return
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# ------------------------------------------------------------------
|
| 84 |
# Operations
|
|
@@ -168,7 +192,9 @@ class SalesforceApp(BaseApp):
|
|
| 168 |
|
| 169 |
rec["owner"] = new_owner
|
| 170 |
rec["_team_assigned"] = True
|
| 171 |
-
|
|
|
|
|
|
|
| 172 |
rec["_intervention_assigned"] = True
|
| 173 |
|
| 174 |
return {"success": True, "schema_adapted": schema_adapted,
|
|
|
|
| 65 |
# ------------------------------------------------------------------
|
| 66 |
|
| 67 |
def account_checked(self) -> bool:
|
| 68 |
+
"""True once get_account was called for the Workflow A customer (Workflow A step A3)."""
|
| 69 |
+
return any(
|
| 70 |
+
r.get("_is_workflow_a_account") and r.get("_account_checked")
|
| 71 |
+
for r in self._records.values()
|
| 72 |
+
)
|
| 73 |
|
| 74 |
def churn_flagged(self) -> bool:
|
| 75 |
+
"""True once flag_churn_risk was called for the at-risk account (Workflow C step C1)."""
|
| 76 |
+
return any(
|
| 77 |
+
r.get("_is_churn_target") and r.get("_churn_flagged")
|
| 78 |
+
for r in self._records.values()
|
| 79 |
+
)
|
| 80 |
|
| 81 |
def team_assigned(self) -> bool:
|
| 82 |
+
"""Legacy free-pass check β kept for backwards compatibility.
|
| 83 |
+
Workflow B no longer uses this; see new_hire_assigned_in_territory()."""
|
| 84 |
return any(r.get("_team_assigned") for r in self._records.values())
|
| 85 |
|
| 86 |
+
def new_hire_assigned_in_territory(self, employee_id: str, territory: str) -> bool:
|
| 87 |
+
"""True once an SF account in `territory` has `employee_id` as its owner
|
| 88 |
+
(Workflow B step B3 β tightened from the free-pass team_assigned check).
|
| 89 |
+
Forces real cross-app data flow: the agent must use the employee_id and territory
|
| 90 |
+
discovered in B1 to satisfy this check."""
|
| 91 |
+
if not employee_id or not territory:
|
| 92 |
+
return False
|
| 93 |
+
return any(
|
| 94 |
+
r.get("territory") == territory
|
| 95 |
+
and r.get("owner") == employee_id
|
| 96 |
+
and r.get("_team_assigned")
|
| 97 |
+
for r in self._records.values()
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
def intervention_assigned(self) -> bool:
|
| 101 |
+
"""True once assign_account_owner called on the churn-risk account (Workflow C step C4)."""
|
| 102 |
+
return any(
|
| 103 |
+
r.get("_is_churn_target") and r.get("_intervention_assigned")
|
| 104 |
+
for r in self._records.values()
|
| 105 |
+
)
|
| 106 |
|
| 107 |
# ------------------------------------------------------------------
|
| 108 |
# Operations
|
|
|
|
| 192 |
|
| 193 |
rec["owner"] = new_owner
|
| 194 |
rec["_team_assigned"] = True
|
| 195 |
+
# Semantic-marker driven: any churn target getting an owner is an intervention.
|
| 196 |
+
# Replaces the old `account_id == "ACME-003"` hardcoded ID check.
|
| 197 |
+
if rec.get("_is_churn_target"):
|
| 198 |
rec["_intervention_assigned"] = True
|
| 199 |
|
| 200 |
return {"success": True, "schema_adapted": schema_adapted,
|
server/apps/workday.py
CHANGED
|
@@ -68,16 +68,29 @@ class WorkdayApp(BaseApp):
|
|
| 68 |
return any(r.get("_sla_logged") for r in self._records.values())
|
| 69 |
|
| 70 |
def employee_created(self) -> bool:
|
| 71 |
-
"""True once create_onboarding_task was called for
|
| 72 |
-
return
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
def access_provisioned(self, app_name: str) -> bool:
|
| 75 |
-
"""True once provision_access was called for the
|
|
|
|
|
|
|
| 76 |
return any(
|
| 77 |
-
r.get("_access_provisioned", {}).get(app_name)
|
| 78 |
for r in self._records.values()
|
| 79 |
)
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
# ------------------------------------------------------------------
|
| 82 |
# Operations
|
| 83 |
# ------------------------------------------------------------------
|
|
@@ -125,7 +138,7 @@ class WorkdayApp(BaseApp):
|
|
| 125 |
if not rec:
|
| 126 |
return {"success": False, "message": f"Employee {employee_id} not found"}
|
| 127 |
|
| 128 |
-
rec.setdefault("_access_provisioned", {})[app_name] = True
|
| 129 |
return {"success": True, "schema_adapted": schema_adapted,
|
| 130 |
"message": f"Provisioned {app_name} access for {employee_id} ({rec.get('name', '')})"}
|
| 131 |
|
|
|
|
| 68 |
return any(r.get("_sla_logged") for r in self._records.values())
|
| 69 |
|
| 70 |
def employee_created(self) -> bool:
|
| 71 |
+
"""True once create_onboarding_task was called for the pending new hire (Workflow B step B1)."""
|
| 72 |
+
return any(
|
| 73 |
+
r.get("_is_new_hire") and r.get("_onboarding_created")
|
| 74 |
+
for r in self._records.values()
|
| 75 |
+
)
|
| 76 |
|
| 77 |
def access_provisioned(self, app_name: str) -> bool:
|
| 78 |
+
"""True once provision_access was called for the NEW HIRE specifically (Workflow B step B2).
|
| 79 |
+
Tightened from any-employee free-pass to requiring _is_new_hire β eliminates the
|
| 80 |
+
old training dead-zone where provisioning a random employee satisfied the check."""
|
| 81 |
return any(
|
| 82 |
+
r.get("_is_new_hire") and r.get("_access_provisioned", {}).get(app_name.lower())
|
| 83 |
for r in self._records.values()
|
| 84 |
)
|
| 85 |
|
| 86 |
+
def get_new_hire(self) -> Optional[Dict]:
|
| 87 |
+
"""Return the new-hire employee record (the one with _is_new_hire), or None.
|
| 88 |
+
Used by workflow_engine.py to thread employee_id / territory into B3 / B4 checks."""
|
| 89 |
+
return next(
|
| 90 |
+
(r for r in self._records.values() if r.get("_is_new_hire")),
|
| 91 |
+
None,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
# ------------------------------------------------------------------
|
| 95 |
# Operations
|
| 96 |
# ------------------------------------------------------------------
|
|
|
|
| 138 |
if not rec:
|
| 139 |
return {"success": False, "message": f"Employee {employee_id} not found"}
|
| 140 |
|
| 141 |
+
rec.setdefault("_access_provisioned", {})[app_name.lower()] = True
|
| 142 |
return {"success": True, "schema_adapted": schema_adapted,
|
| 143 |
"message": f"Provisioned {app_name} access for {employee_id} ({rec.get('name', '')})"}
|
| 144 |
|
server/apps/zendesk.py
CHANGED
|
@@ -62,8 +62,11 @@ class ZendeskApp(BaseApp):
|
|
| 62 |
# ------------------------------------------------------------------
|
| 63 |
|
| 64 |
def ticket_acknowledged(self) -> bool:
|
| 65 |
-
"""True once
|
| 66 |
-
return
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
def support_queried(self, account_id: str) -> bool:
|
| 69 |
"""True once tickets for account_id were listed (Workflow C step C2)."""
|
|
@@ -256,262 +259,4 @@ class ZendeskApp(BaseApp):
|
|
| 256 |
}
|
| 257 |
self._records[employee_id] = profile
|
| 258 |
return {"success": True,
|
| 259 |
-
"message": f"Created Zendesk agent profile for {name or employee_id} ({email})"}
|
| 260 |
-
# """Zendesk-like app β customer support ticket management."""
|
| 261 |
-
|
| 262 |
-
# from typing import Dict, List, Optional
|
| 263 |
-
# from server.apps.base_app import BaseApp
|
| 264 |
-
# from server.schema_drift import SchemaDriftEngine
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
# class ZendeskApp(BaseApp):
|
| 268 |
-
# APP_NAME = "zendesk"
|
| 269 |
-
|
| 270 |
-
# OPERATIONS = [
|
| 271 |
-
# "get_ticket", "acknowledge_ticket", "set_urgency", "assign_agent",
|
| 272 |
-
# "escalate_to_jira", "resolve_ticket", "add_note", "list_tickets","create_agent_profile",
|
| 273 |
-
# ]
|
| 274 |
-
|
| 275 |
-
# def __init__(self, drift: SchemaDriftEngine):
|
| 276 |
-
# super().__init__(drift)
|
| 277 |
-
# self._records: Dict[str, Dict] = {}
|
| 278 |
-
|
| 279 |
-
# # ------------------------------------------------------------------
|
| 280 |
-
# # BaseApp interface
|
| 281 |
-
# # ------------------------------------------------------------------
|
| 282 |
-
|
| 283 |
-
# def initialize(self, records: List[Dict]) -> None:
|
| 284 |
-
# self._records = {r["ticket_number"]: r for r in records}
|
| 285 |
-
|
| 286 |
-
# def execute(self, operation: str, args: Dict) -> Dict:
|
| 287 |
-
# method = getattr(self, f"_op_{operation}", None)
|
| 288 |
-
# if method is None:
|
| 289 |
-
# return {
|
| 290 |
-
# "success": False,
|
| 291 |
-
# "message": f"Unknown operation '{operation}'. Available: {', '.join(self.OPERATIONS)}",
|
| 292 |
-
# }
|
| 293 |
-
# try:
|
| 294 |
-
# return method(**args)
|
| 295 |
-
# except TypeError as exc:
|
| 296 |
-
# return {"success": False, "message": f"Bad args for '{operation}': {exc}"}
|
| 297 |
-
|
| 298 |
-
# def get_state_view(self, max_rows: int = 5) -> str:
|
| 299 |
-
# open_tickets = [r for r in self._records.values()
|
| 300 |
-
# if r.get("state") not in ("resolved", "closed")][:max_rows]
|
| 301 |
-
# if not open_tickets:
|
| 302 |
-
# return "No open tickets."
|
| 303 |
-
# lines = []
|
| 304 |
-
# for rec in open_tickets:
|
| 305 |
-
# view = self._to_agent_view(rec)
|
| 306 |
-
# keep = ["ticket_number", "title",
|
| 307 |
-
# "urgency", "priority", "impact_level",
|
| 308 |
-
# "agent_email", "handler", "assigned_agent",
|
| 309 |
-
# "state", "ticket_state", "resolution_status",
|
| 310 |
-
# "customer_id"]
|
| 311 |
-
# compact = {k: v for k, v in view.items() if k in keep and v is not None}
|
| 312 |
-
# lines.append(str(compact))
|
| 313 |
-
# return "\n".join(lines)
|
| 314 |
-
|
| 315 |
-
# def count_open_items(self) -> int:
|
| 316 |
-
# return sum(1 for r in self._records.values()
|
| 317 |
-
# if r.get("state") not in ("resolved", "closed"))
|
| 318 |
-
|
| 319 |
-
# # ------------------------------------------------------------------
|
| 320 |
-
# # Workflow completion state checks
|
| 321 |
-
# # ------------------------------------------------------------------
|
| 322 |
-
|
| 323 |
-
# def ticket_acknowledged(self) -> bool:
|
| 324 |
-
# """True once ZD-001 has been acknowledged (Workflow A step A1)."""
|
| 325 |
-
# return bool(self._records.get("ZD-001", {}).get("_acknowledged"))
|
| 326 |
-
|
| 327 |
-
# def support_queried(self, account_id: str) -> bool:
|
| 328 |
-
# """True once tickets for account_id were listed (Workflow C step C2)."""
|
| 329 |
-
# return account_id in self._records.get("ZD-001", {}).get("_queried_accounts", []) or \
|
| 330 |
-
# any(account_id in r.get("_queried_accounts", []) for r in self._records.values())
|
| 331 |
-
|
| 332 |
-
# def profile_created(self) -> bool:
|
| 333 |
-
# """True once a new agent profile was created (Workflow B step B4)."""
|
| 334 |
-
# return any(r.get("_profile_created") for r in self._records.values())
|
| 335 |
-
|
| 336 |
-
# # ------------------------------------------------------------------
|
| 337 |
-
# # Operations
|
| 338 |
-
# # ------------------------------------------------------------------
|
| 339 |
-
|
| 340 |
-
# def _op_get_ticket(self, ticket_number: str, customer_id: Optional[str] = None) -> Dict:
|
| 341 |
-
# # If customer_id provided, look up all tickets for that customer
|
| 342 |
-
# if customer_id:
|
| 343 |
-
# matching = [r for r in self._records.values()
|
| 344 |
-
# if r.get("customer_id") == customer_id]
|
| 345 |
-
# # Mark as queried for Workflow C
|
| 346 |
-
# for r in matching:
|
| 347 |
-
# r.setdefault("_queried_accounts", [])
|
| 348 |
-
# if customer_id not in r["_queried_accounts"]:
|
| 349 |
-
# r["_queried_accounts"].append(customer_id)
|
| 350 |
-
# if not matching:
|
| 351 |
-
# return {"success": True, "data": [],
|
| 352 |
-
# "message": f"No tickets found for customer {customer_id}"}
|
| 353 |
-
# return {
|
| 354 |
-
# "success": True,
|
| 355 |
-
# "data": [self._to_agent_view(r) for r in matching[:5]],
|
| 356 |
-
# "message": f"Found {len(matching)} tickets for {customer_id}",
|
| 357 |
-
# }
|
| 358 |
-
|
| 359 |
-
# rec = self._records.get(ticket_number)
|
| 360 |
-
# if not rec:
|
| 361 |
-
# return {"success": False,
|
| 362 |
-
# "message": f"Ticket {ticket_number} not found. Use list_tickets to browse."}
|
| 363 |
-
# rec.setdefault("_queried_accounts", [])
|
| 364 |
-
# cid = rec.get("customer_id")
|
| 365 |
-
# if cid and cid not in rec["_queried_accounts"]:
|
| 366 |
-
# rec["_queried_accounts"].append(cid)
|
| 367 |
-
|
| 368 |
-
# return {"success": True, "data": self._to_agent_view(rec),
|
| 369 |
-
# "ticket": rec,
|
| 370 |
-
# "message": f"Retrieved {ticket_number}"}
|
| 371 |
-
|
| 372 |
-
# def _op_acknowledge_ticket(self, ticket_number: str) -> Dict:
|
| 373 |
-
# rec = self._records.get(ticket_number)
|
| 374 |
-
# if not rec:
|
| 375 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 376 |
-
# rec["_acknowledged"] = True
|
| 377 |
-
# if rec.get("state") == "new":
|
| 378 |
-
# rec["state"] = "open"
|
| 379 |
-
# return {"success": True, "ticket": rec,
|
| 380 |
-
# "message": f"Acknowledged {ticket_number} β status β open"}
|
| 381 |
-
|
| 382 |
-
# def _op_set_urgency(self, ticket_number: str, **kwargs) -> Dict:
|
| 383 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 384 |
-
# if schema_error:
|
| 385 |
-
# hint = self._drift.translate_field("urgency", self.APP_NAME)
|
| 386 |
-
# return {"success": False, "schema_error": schema_error,
|
| 387 |
-
# "message": f"Schema error: use '{hint}' not '{schema_error}'"}
|
| 388 |
-
|
| 389 |
-
# rec = self._records.get(ticket_number)
|
| 390 |
-
# if not rec:
|
| 391 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 392 |
-
|
| 393 |
-
# new_urgency = (kwargs.get("urgency") or kwargs.get("priority")
|
| 394 |
-
# or kwargs.get("impact_level"))
|
| 395 |
-
# if not new_urgency:
|
| 396 |
-
# return {"success": False,
|
| 397 |
-
# "message": "Provide urgency / priority / impact_level value"}
|
| 398 |
-
|
| 399 |
-
# rec["urgency"] = new_urgency
|
| 400 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 401 |
-
# "message": f"{ticket_number} urgency β '{new_urgency}'"}
|
| 402 |
-
|
| 403 |
-
# def _op_assign_agent(self, ticket_number: str, **kwargs) -> Dict:
|
| 404 |
-
# schema_error, schema_adapted = self._check_schema_drift(kwargs)
|
| 405 |
-
# if schema_error:
|
| 406 |
-
# hint = self._drift.translate_field("agent_email", self.APP_NAME)
|
| 407 |
-
# return {"success": False, "schema_error": schema_error,
|
| 408 |
-
# "message": f"Schema error: use '{hint}' not '{schema_error}'"}
|
| 409 |
-
|
| 410 |
-
# rec = self._records.get(ticket_number)
|
| 411 |
-
# # For Workflow B profile creation: allow creating a new agent entry
|
| 412 |
-
# if not rec:
|
| 413 |
-
# # Create a minimal profile record for the new agent
|
| 414 |
-
# email = (kwargs.get("agent_email") or kwargs.get("handler")
|
| 415 |
-
# or kwargs.get("assigned_agent"))
|
| 416 |
-
# if not email:
|
| 417 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 418 |
-
# # Create a synthetic profile ticket
|
| 419 |
-
# profile_rec = {
|
| 420 |
-
# "ticket_number": ticket_number,
|
| 421 |
-
# "title": "Agent profile",
|
| 422 |
-
# "urgency": "p3",
|
| 423 |
-
# "agent_email": email,
|
| 424 |
-
# "state": "closed",
|
| 425 |
-
# "customer_id": None,
|
| 426 |
-
# "_acknowledged": False,
|
| 427 |
-
# "_queried_accounts": [],
|
| 428 |
-
# "_profile_created": True,
|
| 429 |
-
# }
|
| 430 |
-
# self._records[ticket_number] = profile_rec
|
| 431 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 432 |
-
# "message": f"Created Zendesk profile for agent '{email}'"}
|
| 433 |
-
|
| 434 |
-
# email = (kwargs.get("agent_email") or kwargs.get("handler")
|
| 435 |
-
# or kwargs.get("assigned_agent"))
|
| 436 |
-
# if not email:
|
| 437 |
-
# return {"success": False,
|
| 438 |
-
# "message": "Provide agent_email / handler / assigned_agent value"}
|
| 439 |
-
|
| 440 |
-
# rec["agent_email"] = email
|
| 441 |
-
# rec["_profile_created"] = True
|
| 442 |
-
# return {"success": True, "schema_adapted": schema_adapted,
|
| 443 |
-
# "message": f"{ticket_number} assigned to '{email}'"}
|
| 444 |
-
|
| 445 |
-
# def _op_escalate_to_jira(self, ticket_number: str,
|
| 446 |
-
# jira_issue_id: Optional[str] = None) -> Dict:
|
| 447 |
-
# rec = self._records.get(ticket_number)
|
| 448 |
-
# if not rec:
|
| 449 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 450 |
-
# rec["state"] = "pending"
|
| 451 |
-
# rec["escalated_to_jira"] = jira_issue_id or "pending"
|
| 452 |
-
# return {"success": True,
|
| 453 |
-
# "message": f"{ticket_number} escalated to Jira"
|
| 454 |
-
# + (f" ({jira_issue_id})" if jira_issue_id else "")}
|
| 455 |
-
|
| 456 |
-
# def _op_resolve_ticket(self, ticket_number: str) -> Dict:
|
| 457 |
-
# rec = self._records.get(ticket_number)
|
| 458 |
-
# if not rec:
|
| 459 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 460 |
-
# rec["state"] = "resolved"
|
| 461 |
-
# return {"success": True, "message": f"{ticket_number} resolved"}
|
| 462 |
-
|
| 463 |
-
# def _op_add_note(self, ticket_number: str, note: str) -> Dict:
|
| 464 |
-
# rec = self._records.get(ticket_number)
|
| 465 |
-
# if not rec:
|
| 466 |
-
# return {"success": False, "message": f"Ticket {ticket_number} not found"}
|
| 467 |
-
# rec.setdefault("notes", []).append(note)
|
| 468 |
-
# return {"success": True, "message": f"Note added to {ticket_number}"}
|
| 469 |
-
|
| 470 |
-
# def _op_list_tickets(self, state: str = "open", customer_id: Optional[str] = None,
|
| 471 |
-
# limit: int = 10) -> Dict:
|
| 472 |
-
# matching = [
|
| 473 |
-
# r for r in self._records.values()
|
| 474 |
-
# if (state == "all" or r.get("state") == state)
|
| 475 |
-
# and (customer_id is None or r.get("customer_id") == customer_id)
|
| 476 |
-
# ][:limit]
|
| 477 |
-
# # Mark accounts as queried
|
| 478 |
-
# if customer_id:
|
| 479 |
-
# for r in matching:
|
| 480 |
-
# r.setdefault("_queried_accounts", [])
|
| 481 |
-
# if customer_id not in r["_queried_accounts"]:
|
| 482 |
-
# r["_queried_accounts"].append(customer_id)
|
| 483 |
-
|
| 484 |
-
# drifted = [self._to_agent_view(r) for r in matching]
|
| 485 |
-
# keep = ["ticket_number", "title",
|
| 486 |
-
# "urgency", "priority", "impact_level",
|
| 487 |
-
# "agent_email", "handler", "assigned_agent",
|
| 488 |
-
# "state", "ticket_state", "resolution_status",
|
| 489 |
-
# "customer_id"]
|
| 490 |
-
# compact = [{k: v for k, v in r.items() if k in keep and v is not None}
|
| 491 |
-
# for r in drifted]
|
| 492 |
-
# return {
|
| 493 |
-
# "success": True,
|
| 494 |
-
# "data": compact,
|
| 495 |
-
# "message": f"Found {len(compact)} {state} tickets"
|
| 496 |
-
# + (f" for {customer_id}" if customer_id else ""),
|
| 497 |
-
# }
|
| 498 |
-
# def _op_create_agent_profile(self, employee_id: str, email: str,
|
| 499 |
-
# name: Optional[str] = None, **kwargs) -> Dict:
|
| 500 |
-
# """Create a Zendesk support agent profile (Workflow B step B4)."""
|
| 501 |
-
# if employee_id in self._records:
|
| 502 |
-
# return {"success": False,
|
| 503 |
-
# "message": f"Profile for {employee_id} already exists"}
|
| 504 |
-
# profile = {
|
| 505 |
-
# "ticket_number": employee_id,
|
| 506 |
-
# "title": f"Agent profile β {name or employee_id}",
|
| 507 |
-
# "urgency": "p3",
|
| 508 |
-
# "agent_email": email,
|
| 509 |
-
# "state": "closed",
|
| 510 |
-
# "customer_id": None,
|
| 511 |
-
# "_acknowledged": False,
|
| 512 |
-
# "_queried_accounts": [],
|
| 513 |
-
# "_profile_created": True,
|
| 514 |
-
# }
|
| 515 |
-
# self._records[employee_id] = profile
|
| 516 |
-
# return {"success": True,
|
| 517 |
-
# "message": f"Created Zendesk agent profile for {name or employee_id} ({email})"}
|
|
|
|
| 62 |
# ------------------------------------------------------------------
|
| 63 |
|
| 64 |
def ticket_acknowledged(self) -> bool:
|
| 65 |
+
"""True once the primary p1/p0 ticket has been acknowledged (Workflow A step A1)."""
|
| 66 |
+
return any(
|
| 67 |
+
r.get("_is_primary_ticket") and r.get("_acknowledged")
|
| 68 |
+
for r in self._records.values()
|
| 69 |
+
)
|
| 70 |
|
| 71 |
def support_queried(self, account_id: str) -> bool:
|
| 72 |
"""True once tickets for account_id were listed (Workflow C step C2)."""
|
|
|
|
| 259 |
}
|
| 260 |
self._records[employee_id] = profile
|
| 261 |
return {"success": True,
|
| 262 |
+
"message": f"Created Zendesk agent profile for {name or employee_id} ({email})"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/data_generator.py
CHANGED
|
@@ -226,12 +226,13 @@ def generate_jira_records(n: int = 50, seed: int = SEED) -> List[Dict]:
|
|
| 226 |
|
| 227 |
# Workflow A primary issue: JIRA-001 is unassigned, linked to ACME-001
|
| 228 |
records[0].update({
|
| 229 |
-
"title":
|
| 230 |
-
"priority":
|
| 231 |
-
"status":
|
| 232 |
-
"customer_id":
|
| 233 |
-
"assignee":
|
| 234 |
-
"linked_zendesk":
|
|
|
|
| 235 |
})
|
| 236 |
|
| 237 |
return records
|
|
@@ -264,11 +265,12 @@ def generate_zendesk_records(n: int = 40, seed: int = SEED) -> List[Dict]:
|
|
| 264 |
|
| 265 |
# Workflow A primary: ZD-001 is unacknowledged, from ACME-001
|
| 266 |
records[0].update({
|
| 267 |
-
"title":
|
| 268 |
-
"urgency":
|
| 269 |
-
"state":
|
| 270 |
-
"customer_id":
|
| 271 |
-
"_acknowledged":
|
|
|
|
| 272 |
})
|
| 273 |
|
| 274 |
# Workflow C: several tickets from ACME-003
|
|
@@ -313,22 +315,24 @@ def generate_salesforce_records(n: int = 30, seed: int = SEED) -> List[Dict]:
|
|
| 313 |
|
| 314 |
# Workflow A: ACME-001 is a paying customer with yellow health
|
| 315 |
records[0].update({
|
| 316 |
-
"company_name":
|
| 317 |
-
"deal_stage":
|
| 318 |
-
"health":
|
| 319 |
-
"is_paying":
|
| 320 |
-
"arr":
|
| 321 |
-
"territory":
|
|
|
|
| 322 |
})
|
| 323 |
|
| 324 |
# Workflow C: ACME-003 is at churn risk
|
| 325 |
records[2].update({
|
| 326 |
-
"company_name":
|
| 327 |
-
"health":
|
| 328 |
-
"deal_stage":
|
| 329 |
-
"is_paying":
|
| 330 |
-
"arr":
|
| 331 |
-
"_churn_flagged":
|
|
|
|
| 332 |
})
|
| 333 |
|
| 334 |
return records
|
|
@@ -348,7 +352,9 @@ def generate_workday_records(n: int = 20, seed: int = SEED) -> List[Dict]:
|
|
| 348 |
"name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}",
|
| 349 |
"level": random.choice(levels),
|
| 350 |
"manager_id": f"EMP-{random.randint(1, min(i, 5)):03d}" if i > 1 else None,
|
| 351 |
-
|
|
|
|
|
|
|
| 352 |
"department": random.choice(departments),
|
| 353 |
"territory": random.choice(territories),
|
| 354 |
"email": f"emp{i}@company.com",
|
|
@@ -371,6 +377,7 @@ def generate_workday_records(n: int = 20, seed: int = SEED) -> List[Dict]:
|
|
| 371 |
"_access_provisioned": {},
|
| 372 |
"_sla_logged": False,
|
| 373 |
"_onboarding_created": False,
|
|
|
|
| 374 |
})
|
| 375 |
|
| 376 |
return records
|
|
|
|
| 226 |
|
| 227 |
# Workflow A primary issue: JIRA-001 is unassigned, linked to ACME-001
|
| 228 |
records[0].update({
|
| 229 |
+
"title": "Customer login fails intermittently",
|
| 230 |
+
"priority": "p1",
|
| 231 |
+
"status": "open",
|
| 232 |
+
"customer_id": "ACME-001",
|
| 233 |
+
"assignee": None,
|
| 234 |
+
"linked_zendesk": None,
|
| 235 |
+
"_is_primary_bug": True, # semantic marker β used by issue_assigned() check
|
| 236 |
})
|
| 237 |
|
| 238 |
return records
|
|
|
|
| 265 |
|
| 266 |
# Workflow A primary: ZD-001 is unacknowledged, from ACME-001
|
| 267 |
records[0].update({
|
| 268 |
+
"title": "Login issue β cannot access my account",
|
| 269 |
+
"urgency": "p1",
|
| 270 |
+
"state": "new",
|
| 271 |
+
"customer_id": "ACME-001",
|
| 272 |
+
"_acknowledged": False,
|
| 273 |
+
"_is_primary_ticket": True, # semantic marker β used by ticket_acknowledged() check
|
| 274 |
})
|
| 275 |
|
| 276 |
# Workflow C: several tickets from ACME-003
|
|
|
|
| 315 |
|
| 316 |
# Workflow A: ACME-001 is a paying customer with yellow health
|
| 317 |
records[0].update({
|
| 318 |
+
"company_name": "Acme Corporation",
|
| 319 |
+
"deal_stage": "closed_won",
|
| 320 |
+
"health": "yellow",
|
| 321 |
+
"is_paying": True,
|
| 322 |
+
"arr": 50_000,
|
| 323 |
+
"territory": "west",
|
| 324 |
+
"_is_workflow_a_account": True, # semantic marker β used by account_checked() check
|
| 325 |
})
|
| 326 |
|
| 327 |
# Workflow C: ACME-003 is at churn risk
|
| 328 |
records[2].update({
|
| 329 |
+
"company_name": "Globex Systems",
|
| 330 |
+
"health": "red",
|
| 331 |
+
"deal_stage": "negotiation",
|
| 332 |
+
"is_paying": True,
|
| 333 |
+
"arr": 30_000,
|
| 334 |
+
"_churn_flagged": False,
|
| 335 |
+
"_is_churn_target": True, # semantic marker β used by churn_flagged() / intervention_assigned()
|
| 336 |
})
|
| 337 |
|
| 338 |
return records
|
|
|
|
| 352 |
"name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}",
|
| 353 |
"level": random.choice(levels),
|
| 354 |
"manager_id": f"EMP-{random.randint(1, min(i, 5)):03d}" if i > 1 else None,
|
| 355 |
+
# All random employees are "active" β only the new hire below has status="pending"
|
| 356 |
+
# so list_employees(status="pending") returns exactly one (discoverable) result.
|
| 357 |
+
"status": "active",
|
| 358 |
"department": random.choice(departments),
|
| 359 |
"territory": random.choice(territories),
|
| 360 |
"email": f"emp{i}@company.com",
|
|
|
|
| 377 |
"_access_provisioned": {},
|
| 378 |
"_sla_logged": False,
|
| 379 |
"_onboarding_created": False,
|
| 380 |
+
"_is_new_hire": True, # semantic marker β used by employee_created() check
|
| 381 |
})
|
| 382 |
|
| 383 |
return records
|
server/environment.py
CHANGED
|
@@ -137,12 +137,21 @@ class OrgOSEnvironment:
|
|
| 137 |
self._schema_score = min(1.0, self._schema_score + 0.10)
|
| 138 |
self._policy_score = min(1.0, self._policy_score + 0.05)
|
| 139 |
|
| 140 |
-
# Earn compliance
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
|
| 144 |
# 5. Re-evaluate workflow completion
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
# 6. SLA check (only if a ticket was touched)
|
| 148 |
sla_ok, sla_pen = self._rules.check_sla(
|
|
|
|
| 137 |
self._schema_score = min(1.0, self._schema_score + 0.10)
|
| 138 |
self._policy_score = min(1.0, self._policy_score + 0.05)
|
| 139 |
|
| 140 |
+
# Earn compliance for every successful compliant action,
|
| 141 |
+
# normalised so completing all workflow steps earns exactly 1.0.
|
| 142 |
+
total_steps = max(1, len(self._workflow._steps))
|
| 143 |
+
earn_rate = 1.0 / total_steps
|
| 144 |
+
self._rule_score = min(1.0, self._rule_score + earn_rate)
|
| 145 |
|
| 146 |
# 5. Re-evaluate workflow completion
|
| 147 |
+
old_wf_score = self._wf_score
|
| 148 |
+
self._wf_score = self._workflow.evaluate(self._apps)
|
| 149 |
+
wf_advanced = self._wf_score > old_wf_score
|
| 150 |
+
|
| 151 |
+
# Earn efficiency ONLY when a new workflow step was just completed.
|
| 152 |
+
# This penalises padding: closing random tickets, repeating ops, etc.
|
| 153 |
+
if wf_advanced:
|
| 154 |
+
self._efficiency = min(1.0, self._efficiency + earn_rate)
|
| 155 |
|
| 156 |
# 6. SLA check (only if a ticket was touched)
|
| 157 |
sla_ok, sla_pen = self._rules.check_sla(
|
server/workflow_engine.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Workflow engine β defines and evaluates multi-app workflow completion."""
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
| 4 |
-
from typing import Callable, Dict, List
|
| 5 |
|
| 6 |
|
| 7 |
@dataclass
|
|
@@ -14,6 +14,51 @@ class WorkflowStep:
|
|
| 14 |
completion_check: Callable[[Dict], bool]
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# ---------------------------------------------------------------------------
|
| 18 |
# Workflow A: Customer Bug Fix (Zendesk β Jira β Salesforce β Workday)
|
| 19 |
# Agent role: support
|
|
@@ -35,7 +80,7 @@ WORKFLOW_A_STEPS = [
|
|
| 35 |
lambda apps: apps["salesforce"].account_checked(),
|
| 36 |
),
|
| 37 |
WorkflowStep(
|
| 38 |
-
"A4", "Assign the
|
| 39 |
"jira", "assign_owner",
|
| 40 |
lambda apps: apps["jira"].issue_assigned(),
|
| 41 |
),
|
|
@@ -52,24 +97,30 @@ WORKFLOW_A_STEPS = [
|
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
WORKFLOW_B_STEPS = [
|
| 54 |
WorkflowStep(
|
| 55 |
-
"B1",
|
|
|
|
|
|
|
| 56 |
"workday", "create_onboarding_task",
|
| 57 |
lambda apps: apps["workday"].employee_created(),
|
| 58 |
),
|
| 59 |
WorkflowStep(
|
| 60 |
-
"B2",
|
|
|
|
| 61 |
"workday", "provision_access",
|
| 62 |
lambda apps: apps["workday"].access_provisioned("jira"),
|
| 63 |
),
|
| 64 |
WorkflowStep(
|
| 65 |
-
"B3",
|
|
|
|
|
|
|
| 66 |
"salesforce", "assign_account_owner",
|
| 67 |
-
|
| 68 |
),
|
| 69 |
WorkflowStep(
|
| 70 |
-
"B4",
|
| 71 |
-
"
|
| 72 |
-
|
|
|
|
| 73 |
),
|
| 74 |
]
|
| 75 |
|
|
@@ -84,14 +135,16 @@ WORKFLOW_C_STEPS = [
|
|
| 84 |
lambda apps: apps["salesforce"].churn_flagged(),
|
| 85 |
),
|
| 86 |
WorkflowStep(
|
| 87 |
-
"C2", "Query recent support tickets for the at-risk account in Zendesk"
|
|
|
|
| 88 |
"zendesk", "get_ticket",
|
| 89 |
-
|
| 90 |
),
|
| 91 |
WorkflowStep(
|
| 92 |
-
"C3", "List open Jira bugs
|
|
|
|
| 93 |
"jira", "list_issues",
|
| 94 |
-
|
| 95 |
),
|
| 96 |
WorkflowStep(
|
| 97 |
"C4", "Assign an intervention owner to the at-risk account in Salesforce",
|
|
@@ -113,11 +166,13 @@ WORKFLOW_GOALS: Dict[str, str] = {
|
|
| 113 |
"Use list operations to discover relevant record IDs before acting."
|
| 114 |
),
|
| 115 |
"B": (
|
| 116 |
-
"Workflow B β
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
"assign them
|
| 120 |
-
"
|
|
|
|
|
|
|
| 121 |
),
|
| 122 |
"C": (
|
| 123 |
"Workflow C β Churn Risk Alert: "
|
|
|
|
| 1 |
"""Workflow engine β defines and evaluates multi-app workflow completion."""
|
| 2 |
|
| 3 |
from dataclasses import dataclass
|
| 4 |
+
from typing import Callable, Dict, List, Optional
|
| 5 |
|
| 6 |
|
| 7 |
@dataclass
|
|
|
|
| 14 |
completion_check: Callable[[Dict], bool]
|
| 15 |
|
| 16 |
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Helpers β look up semantic-marker targets at evaluation time so completion
|
| 19 |
+
# checks are not coupled to specific record IDs in the data generator.
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
def _churn_target_account_id(apps: Dict) -> Optional[str]:
|
| 23 |
+
"""Return the SF account_id flagged as the churn target this episode."""
|
| 24 |
+
for aid, rec in apps["salesforce"]._records.items():
|
| 25 |
+
if rec.get("_is_churn_target"):
|
| 26 |
+
return aid
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _new_hire_assigned_sf(apps: Dict) -> bool:
|
| 31 |
+
"""Workflow B step B3: the new hire (from Workday) must be the SF owner of an account
|
| 32 |
+
in their own territory. Threads employee_id + territory across apps automatically."""
|
| 33 |
+
new_hire = apps["workday"].get_new_hire()
|
| 34 |
+
if not new_hire:
|
| 35 |
+
return False
|
| 36 |
+
return apps["salesforce"].new_hire_assigned_in_territory(
|
| 37 |
+
new_hire.get("employee_id", ""),
|
| 38 |
+
new_hire.get("territory", ""),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _new_hire_assigned_jira(apps: Dict) -> bool:
|
| 43 |
+
"""Workflow B step B4: an open Jira issue must be assigned to the new hire's employee_id."""
|
| 44 |
+
new_hire = apps["workday"].get_new_hire()
|
| 45 |
+
if not new_hire:
|
| 46 |
+
return False
|
| 47 |
+
return apps["jira"].new_hire_assigned_to_issue(new_hire.get("employee_id", ""))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _support_queried_for_churn_target(apps: Dict) -> bool:
|
| 51 |
+
"""Workflow C step C2: Zendesk must have been queried for the churn target's account."""
|
| 52 |
+
aid = _churn_target_account_id(apps)
|
| 53 |
+
return bool(aid) and apps["zendesk"].support_queried(aid)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _bugs_checked_for_churn_target(apps: Dict) -> bool:
|
| 57 |
+
"""Workflow C step C3: Jira list_issues must have been called with customer_id=<churn target>."""
|
| 58 |
+
aid = _churn_target_account_id(apps)
|
| 59 |
+
return bool(aid) and apps["jira"].bugs_checked_for(aid)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
# ---------------------------------------------------------------------------
|
| 63 |
# Workflow A: Customer Bug Fix (Zendesk β Jira β Salesforce β Workday)
|
| 64 |
# Agent role: support
|
|
|
|
| 80 |
lambda apps: apps["salesforce"].account_checked(),
|
| 81 |
),
|
| 82 |
WorkflowStep(
|
| 83 |
+
"A4", "Assign the Jira issue you just created (linked to the Zendesk ticket) to an engineer",
|
| 84 |
"jira", "assign_owner",
|
| 85 |
lambda apps: apps["jira"].issue_assigned(),
|
| 86 |
),
|
|
|
|
| 97 |
# ---------------------------------------------------------------------------
|
| 98 |
WORKFLOW_B_STEPS = [
|
| 99 |
WorkflowStep(
|
| 100 |
+
"B1",
|
| 101 |
+
"Find the pending new hire in Workday (list_employees status=pending returns one result) "
|
| 102 |
+
"and create their onboarding record",
|
| 103 |
"workday", "create_onboarding_task",
|
| 104 |
lambda apps: apps["workday"].employee_created(),
|
| 105 |
),
|
| 106 |
WorkflowStep(
|
| 107 |
+
"B2",
|
| 108 |
+
"Provision Jira access for THAT new hire (use the employee_id from B1)",
|
| 109 |
"workday", "provision_access",
|
| 110 |
lambda apps: apps["workday"].access_provisioned("jira"),
|
| 111 |
),
|
| 112 |
WorkflowStep(
|
| 113 |
+
"B3",
|
| 114 |
+
"Assign the new hire as Salesforce account owner for an account in their own territory "
|
| 115 |
+
"(use employee_id and territory from B1)",
|
| 116 |
"salesforce", "assign_account_owner",
|
| 117 |
+
_new_hire_assigned_sf,
|
| 118 |
),
|
| 119 |
WorkflowStep(
|
| 120 |
+
"B4",
|
| 121 |
+
"Assign an open Jira issue to the new hire (use employee_id from B1 as the assignee)",
|
| 122 |
+
"jira", "assign_owner",
|
| 123 |
+
_new_hire_assigned_jira,
|
| 124 |
),
|
| 125 |
]
|
| 126 |
|
|
|
|
| 135 |
lambda apps: apps["salesforce"].churn_flagged(),
|
| 136 |
),
|
| 137 |
WorkflowStep(
|
| 138 |
+
"C2", "Query recent support tickets for the at-risk account in Zendesk "
|
| 139 |
+
"(use the account_id from C1, not a hardcoded value)",
|
| 140 |
"zendesk", "get_ticket",
|
| 141 |
+
_support_queried_for_churn_target,
|
| 142 |
),
|
| 143 |
WorkflowStep(
|
| 144 |
+
"C3", "List open Jira bugs for the at-risk account "
|
| 145 |
+
"(call list_issues with customer_id=<churn account>)",
|
| 146 |
"jira", "list_issues",
|
| 147 |
+
_bugs_checked_for_churn_target,
|
| 148 |
),
|
| 149 |
WorkflowStep(
|
| 150 |
"C4", "Assign an intervention owner to the at-risk account in Salesforce",
|
|
|
|
| 166 |
"Use list operations to discover relevant record IDs before acting."
|
| 167 |
),
|
| 168 |
"B": (
|
| 169 |
+
"Workflow B β Manager-Aware Onboarding: "
|
| 170 |
+
"One employee in Workday is currently pending onboarding (status=pending). "
|
| 171 |
+
"Find that pending employee, create their onboarding record, provision their Jira access, "
|
| 172 |
+
"assign them as the Salesforce account owner for an account in THEIR OWN territory, "
|
| 173 |
+
"and assign them ownership of an open Jira issue. "
|
| 174 |
+
"Each step's output feeds the next β capture the employee_id and territory from step 1 "
|
| 175 |
+
"and reuse them in subsequent steps."
|
| 176 |
),
|
| 177 |
"C": (
|
| 178 |
"Workflow C β Churn Risk Alert: "
|