From 0ae4594af5a6b57d601abd9cda1b55149daae923 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Mon, 21 Sep 2026 13:02:19 -0700 Subject: [PATCH 1/5] feat(samples): add ticket-triage-agent with System 1/System 2 triage Two-tier support ticket triage: a mocked "System One" model (Jev, stubbed locally rather than pulled from the unverified same-day typesafe-sdk package) does fast structured routing/urgency/frustration scoring, then either escalates to an Action Center HITL task or drafts an auto-reply via the LLM Gateway. Includes an eval set covering both branches across all three departments. escalate_to_human passes folder_path to create_quickform, which requires an Orchestrator folder (RequireOrganizationUnit); verified end-to-end against a real tenant, including real Action Center task creation. Co-Authored-By: Claude Sonnet 5 --- .../samples/ticket-triage-agent/.env.example | 11 + .../samples/ticket-triage-agent/README.md | 151 ++++++++++++ .../samples/ticket-triage-agent/bindings.json | 4 + .../evaluations/eval-sets/default.json | 143 ++++++++++++ .../evaluators/department-routing.json | 17 ++ .../evaluators/escalation-decision.json | 16 ++ .../samples/ticket-triage-agent/input.json | 4 + .../samples/ticket-triage-agent/jev_client.py | 213 +++++++++++++++++ .../samples/ticket-triage-agent/main.py | 215 ++++++++++++++++++ .../ticket-triage-agent/pyproject.toml | 19 ++ .../samples/ticket-triage-agent/uipath.json | 6 + 11 files changed, 799 insertions(+) create mode 100644 packages/uipath/samples/ticket-triage-agent/.env.example create mode 100644 packages/uipath/samples/ticket-triage-agent/README.md create mode 100644 packages/uipath/samples/ticket-triage-agent/bindings.json create mode 100644 packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json create mode 100644 packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json create mode 100644 packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/escalation-decision.json create mode 100644 packages/uipath/samples/ticket-triage-agent/input.json create mode 100644 packages/uipath/samples/ticket-triage-agent/jev_client.py create mode 100644 packages/uipath/samples/ticket-triage-agent/main.py create mode 100644 packages/uipath/samples/ticket-triage-agent/pyproject.toml create mode 100644 packages/uipath/samples/ticket-triage-agent/uipath.json diff --git a/packages/uipath/samples/ticket-triage-agent/.env.example b/packages/uipath/samples/ticket-triage-agent/.env.example new file mode 100644 index 000000000..3d2bec222 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/.env.example @@ -0,0 +1,11 @@ +UIPATH_URL=https://cloud.uipath.com/your_org/your_tenant +UIPATH_ACCESS_TOKEN=your_access_token_here + +# Required for the escalation path: Action Center tasks must be created in +# an Orchestrator folder. +UIPATH_FOLDER_PATH=Shared + +# Not needed to run this sample: jev_client.py is a local, offline mock and +# reads no environment variables. This would only apply if you swap in the +# real typesafe-sdk package (see README.md) after independently vetting it. +# TYPESAFE_API_KEY=your_typesafe_api_key_here diff --git a/packages/uipath/samples/ticket-triage-agent/README.md b/packages/uipath/samples/ticket-triage-agent/README.md new file mode 100644 index 000000000..adace0319 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/README.md @@ -0,0 +1,151 @@ +# Ticket Triage Agent: System 1 / System 2 Pattern + +This sample demonstrates a two-tier triage pattern for support tickets: + +1. **Tier 1 (System 1) - fast structured triage.** A "System One" model + takes the ticket text and a set of typed questions, and returns + calibrated typed answers (a classification, a boolean, and a score) + instead of free text - orders of magnitude cheaper and faster than an + LLM call. This sample stubs that tier with a mock of TypeSafe AI's `Jev` + model. +2. **Tier 2 (System 2) - branch on the triage result.** + - If the ticket is urgent, the customer sounds frustrated, or the + department routing is low-confidence, the agent **escalates to a + human** via a UiPath Action Center QuickForm task, attaching the Tier 1 + decision as context so the reviewer isn't starting cold. + - Otherwise, the agent calls a **real LLM** (UiPath LLM Gateway) with a + department-specific system prompt to draft a reply, and returns it as + an auto-resolved ticket. No human, and no expensive LLM call for + routing, is needed for the common case. + +## About the "Jev" model (important - read this) + +**`jev_client.py` in this sample is a local mock, not a real UiPath or +TypeSafe AI integration.** TypeSafe AI's real `typesafe-sdk` PyPI package +was published the same day this sample was written, already has several +releases, and lists an unusual dependency (`httpx2` instead of `httpx`). +Rather than pull an unverified, very-recently-published third-party package +into this SDK's samples, `jev_client.py` hand-rolls a small deterministic +stand-in that mirrors the real SDK's documented public shape: + +```python +from typesafe_sdk import Choice, Noul, Score, TypeSafeClient + +client = TypeSafeClient() +response = client.system_one(state=..., questions={...}) +``` + +The mock scores keyword signals in the ticket text instead of calling any +external model, so this sample runs fully offline. If you want to use the +real `typesafe-sdk` package, **independently vet it first**, then swap the +import in `main.py` (`from jev_client import ...` -> `from typesafe_sdk +import ...`) and add it to `pyproject.toml` - the public shape used here +(`TypeSafeClient`, `Choice`/`Noul`/`Score`, `.system_one(...)`) is designed +to match the real SDK, so no other code should need to change. + +**No API key is needed to run this sample.** `jev_client.py` is a local, +offline mock and reads no environment variables. `TYPESAFE_API_KEY` (in +`.env.example`, commented out) would only be needed if you later swap in +the real `typesafe-sdk` package, after independently vetting it. + +## Prerequisites + +* [UV package manager](https://docs.astral.sh/uv/) installed +* A UiPath Orchestrator tenant with: + * Access to the LLM Gateway (for the auto-reply path) + * Access to Action Center (for the escalation path) + +## Setup + +### Step 1: Create and activate a virtual environment + +```bash +uv venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +``` + +### Step 2: Install dependencies + +```bash +uv sync +``` + +### Step 3: Configure credentials + +Copy `.env.example` to `.env` and fill in your Orchestrator URL and access +token: + +```bash +cp .env.example .env +``` + +### Step 4: Initialize the agent + +```bash +uv run uipath init +``` + +### Step 5: Run the agent + +```bash +uipath run main --input-file input.json +``` + +Try editing `input.json` to see both branches: + +* A calm, clearly-worded billing request (like the default input) -> the + agent auto-drafts a reply via the LLM Gateway and returns it directly. +* An urgent or angry-sounding message (e.g. mentioning "urgent", "ASAP", or + "furious") -> the agent creates an Action Center task instead and returns + its task id. + +## How it works + +1. `triage_ticket` sends the ticket's subject/message to the mock `Jev` + client as `state`, along with three typed `questions` + (`department: Choice`, `is_urgent: Noul`, `frustration: Score`), and gets + back a `TriageDecision`. +2. `needs_escalation` checks the triage output against fixed thresholds + (urgency, frustration, routing confidence) in `main.py`. +3. On escalation, `escalate_to_human` creates an Action Center QuickForm + task via `client.tasks.create_quickform(...)`, with the ticket and the + triage decision as task data for the reviewer. +4. Otherwise, `draft_auto_reply` calls `client.llm.chat_completions(...)` + (UiPath LLM Gateway) with a department-specific system prompt to draft a + reply. + +## Evaluations + +`evaluations/eval-sets/default.json` exercises the Tier 1 routing/escalation +logic against 11 tickets (billing, technical, and sales; calm and +auto-replied vs. urgent/angry/low-confidence and escalated), using two +evaluators: + +* `DepartmentRoutingEvaluator` (`evaluations/evaluators/department-routing.json`) + - a `uipath-multiclass-classification` evaluator checking + `triage.department` against the expected class. +* `EscalationDecisionEvaluator` (`evaluations/evaluators/escalation-decision.json`) + - a `uipath-binary-classification` evaluator checking the `escalated` + boolean against the expected outcome. + +Two cases (`sales-demo-request-wrong-department`, +`billing-refund-calm-wrong-escalation`) have deliberately wrong ground truth, +mirroring the pattern in `classification_agent`, to demonstrate the +evaluators catching a mismatch. + +Since `draft_auto_reply` and `escalate_to_human` call real UiPath services +(LLM Gateway, Action Center), running the full eval set end-to-end requires +valid credentials in `.env`: + +```bash +uipath eval +``` + +## Publish your coded agent + +Once tested locally, publish the agent to Orchestrator: + +```bash +uipath pack +uipath publish +``` diff --git a/packages/uipath/samples/ticket-triage-agent/bindings.json b/packages/uipath/samples/ticket-triage-agent/bindings.json new file mode 100644 index 000000000..5e9beeb01 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} diff --git a/packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json b/packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json new file mode 100644 index 000000000..01213ee8c --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/evaluations/eval-sets/default.json @@ -0,0 +1,143 @@ +{ + "version": "1.0", + "id": "TicketTriageEval", + "name": "Ticket Triage Routing & Escalation Evaluation", + "evaluatorRefs": [ + "DepartmentRoutingEvaluator", + "EscalationDecisionEvaluator" + ], + "evaluations": [ + { + "id": "billing-refund-calm", + "name": "Billing - calm refund request (auto-reply)", + "inputs": { + "subject": "Duplicate charge on my account", + "message": "Hi, I was charged twice for order A-104. Could you please refund the duplicate charge? Thanks for your help." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "billing" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "billing-invoice-question", + "name": "Billing - invoice clarification (auto-reply)", + "inputs": { + "subject": "Question about my invoice", + "message": "I noticed my invoice this month includes a charge I do not recognize. Could you clarify the payment breakdown? Thanks." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "billing" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "technical-bug-report", + "name": "Technical - routine bug report (auto-reply)", + "inputs": { + "subject": "App crashes on login", + "message": "The app crashes every time I try to log in. This started after the last update. Please help me fix this bug." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "technical" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "technical-outage-urgent", + "name": "Technical - urgent outage (escalate: high urgency)", + "inputs": { + "subject": "URGENT: service down", + "message": "Our integration has been down for 3 days and this is unacceptable. We need this fixed immediately, it is critical." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "technical" }, + "EscalationDecisionEvaluator": { "expectedClass": "true" } + } + }, + { + "id": "sales-pricing-question", + "name": "Sales - pricing inquiry (auto-reply)", + "inputs": { + "subject": "Question about pricing plans", + "message": "Hi, I would like a quote for upgrading to your premium plan. Can you share pricing details?" + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "sales" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "sales-demo-request", + "name": "Sales - demo request (auto-reply)", + "inputs": { + "subject": "Requesting a demo", + "message": "We are considering your product and would like to schedule a demo to see the upgrade options." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "sales" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "billing-angry-customer", + "name": "Billing - angry customer (escalate: high frustration)", + "inputs": { + "subject": "This is ridiculous", + "message": "I have been charged the wrong amount again and I am furious, this is unacceptable and terrible service." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "billing" }, + "EscalationDecisionEvaluator": { "expectedClass": "true" } + } + }, + { + "id": "technical-calm-minor-bug", + "name": "Technical - minor, no-rush bug (auto-reply)", + "inputs": { + "subject": "Minor bug report", + "message": "Just a small bug: the export button is not working as expected. No rush, whenever you get a chance, thanks." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "technical" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "ambiguous-low-confidence", + "name": "Ambiguous ticket (escalate: low routing confidence)", + "inputs": { + "subject": "Quick question", + "message": "Can someone tell me more about how things work around here?" + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "billing" }, + "EscalationDecisionEvaluator": { "expectedClass": "true" } + } + }, + { + "id": "sales-demo-request-wrong-department", + "name": "Sales - demo request (DELIBERATELY WRONG: ground truth department set to 'technical')", + "inputs": { + "subject": "Requesting a demo", + "message": "We are considering your product and would like to schedule a demo to see the upgrade options." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "technical" }, + "EscalationDecisionEvaluator": { "expectedClass": "false" } + } + }, + { + "id": "billing-refund-calm-wrong-escalation", + "name": "Billing - calm refund request (DELIBERATELY WRONG: ground truth escalation set to 'true')", + "inputs": { + "subject": "Duplicate charge on my account", + "message": "Hi, I was charged twice for order A-104. Could you please refund the duplicate charge? Thanks for your help." + }, + "evaluationCriterias": { + "DepartmentRoutingEvaluator": { "expectedClass": "billing" }, + "EscalationDecisionEvaluator": { "expectedClass": "true" } + } + } + ] +} diff --git a/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json b/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json new file mode 100644 index 000000000..ef9723183 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/department-routing.json @@ -0,0 +1,17 @@ +{ + "version": "1.0", + "id": "DepartmentRoutingEvaluator", + "name": "DepartmentRoutingEvaluator", + "description": "Checks whether Tier 1 (Jev) routed the ticket to the expected department", + "evaluatorTypeId": "uipath-multiclass-classification", + "evaluatorConfig": { + "name": "DepartmentRoutingEvaluator", + "targetOutputKey": "triage.department", + "classes": ["billing", "technical", "sales"], + "metricType": "precision", + "averaging": "macro", + "defaultEvaluationCriteria": { + "expectedClass": "technical" + } + } +} diff --git a/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/escalation-decision.json b/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/escalation-decision.json new file mode 100644 index 000000000..857cf32d6 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/evaluations/evaluators/escalation-decision.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "id": "EscalationDecisionEvaluator", + "name": "EscalationDecisionEvaluator", + "description": "Checks whether the agent escalated to a human (Action Center) exactly when it should have", + "evaluatorTypeId": "uipath-binary-classification", + "evaluatorConfig": { + "name": "EscalationDecisionEvaluator", + "targetOutputKey": "escalated", + "positiveClass": "true", + "metricType": "f-score", + "defaultEvaluationCriteria": { + "expectedClass": "false" + } + } +} diff --git a/packages/uipath/samples/ticket-triage-agent/input.json b/packages/uipath/samples/ticket-triage-agent/input.json new file mode 100644 index 000000000..0bf583fdf --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/input.json @@ -0,0 +1,4 @@ +{ + "subject": "Duplicate charge on my account", + "message": "Hi, I was charged twice for order A-104. Could you please refund the duplicate charge? Thanks for your help." +} diff --git a/packages/uipath/samples/ticket-triage-agent/jev_client.py b/packages/uipath/samples/ticket-triage-agent/jev_client.py new file mode 100644 index 000000000..627fb5ef2 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/jev_client.py @@ -0,0 +1,213 @@ +"""Local stub for TypeSafe AI's "Jev" System One model client. + +*** THIS IS A MOCK, NOT A REAL UIPATH OR TYPESAFE AI PRODUCT INTEGRATION. *** + +TypeSafe AI's `typesafe-sdk` PyPI package was published the same day this +sample was written, has an unusual dependency (`httpx2`), and has not been +independently vetted. Rather than pull an unverified, brand-new third-party +package into this SDK's samples, this module hand-rolls a small stand-in +that mimics the real SDK's public shape: + + from typesafe_sdk import Choice, Noul, Score, TypeSafeClient + + client = TypeSafeClient() + response = client.system_one(state=..., questions={...}) + +Once you have independently vetted the real `typesafe-sdk` package (or any +other System One provider), swapping it in is a one-line change: replace the +import below with the real package's import and delete this file. Nothing +else in `main.py` needs to change, since the public shape (`TypeSafeClient`, +`Choice`/`Noul`/`Score`, `.system_one(...)`) is mirrored here on purpose. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Choice: + """Declares a multi-option classification question.""" + + instructions: str + criteria: dict[str, str] + + +@dataclass +class Noul: + """Declares a boolean (0-1 probability) question.""" + + instructions: str + criteria: dict[str, str] | None = None + + +@dataclass +class Score: + """Declares a ranked-scale question.""" + + instructions: str + criteria: list[str] + + +@dataclass +class ChoiceAnswer: + """Typed answer to a `Choice` question.""" + + choice: str + confidence: float + probabilities: dict[str, float] + + +@dataclass +class NoulAnswer: + """Typed answer to a `Noul` question.""" + + noul: float + + +@dataclass +class ScoreAnswer: + """Typed answer to a `Score` question.""" + + score: float + confidence: float + probabilities: dict[str, float] + + +@dataclass +class SystemOneResponse: + """Mirrors the real SDK's response envelope.""" + + model: str + answers: dict[str, ChoiceAnswer | NoulAnswer | ScoreAnswer] + usage: dict[str, int] + + +def _keyword_score(text: str, keywords: dict[str, float]) -> float: + """Deterministically scores text against a keyword table (0-1).""" + lowered = text.lower() + score = sum(weight for keyword, weight in keywords.items() if keyword in lowered) + return max(0.0, min(1.0, score)) + + +class TypeSafeClient: + """Mock stand-in for `typesafe_sdk.TypeSafeClient`. + + Simulates a fast, deterministic "System One" model: instead of an LLM + call, it scores keyword signals in the ticket text to produce calibrated + typed answers, matching the shape the real API documents (see + https://developers.cloudflare.com/ai/models/typesafe/jev/). + """ + + def __init__(self, model: str = "jev-1.13.0-mock") -> None: + self.model = model + + def system_one( + self, *, state: dict[str, Any], questions: dict[str, Choice | Noul | Score] + ) -> SystemOneResponse: + text = " ".join( + str(value) for key in ("subject", "message") if (value := state.get(key)) + ) + + answers: dict[str, ChoiceAnswer | NoulAnswer | ScoreAnswer] = {} + for name, question in questions.items(): + if isinstance(question, Choice): + answers[name] = self._answer_choice(text, question) + elif isinstance(question, Noul): + answers[name] = self._answer_noul(text) + elif isinstance(question, Score): + answers[name] = self._answer_score(text, question) + else: # pragma: no cover - defensive + raise TypeError(f"Unsupported question type for {name!r}: {question!r}") + + input_tokens = len(text.split()) * 2 + 40 + return SystemOneResponse( + model=self.model, + answers=answers, + usage={"input_tokens": input_tokens, "output_tokens": len(answers) * 12}, + ) + + def _answer_choice(self, text: str, question: Choice) -> ChoiceAnswer: + department_keywords = { + "billing": { + "charge": 0.6, + "refund": 0.6, + "invoice": 0.6, + "payment": 0.5, + "subscription": 0.4, + }, + "technical": { + "error": 0.6, + "bug": 0.6, + "crash": 0.6, + "down": 0.5, + "not working": 0.5, + "integration": 0.4, + }, + "sales": { + "pricing": 0.6, + "upgrade": 0.6, + "plan": 0.4, + "demo": 0.5, + "quote": 0.5, + }, + } + raw_scores = { + option: _keyword_score(text, department_keywords.get(option, {})) + for option in question.criteria + } + # Deterministic tie-break salt so identical zero-scores don't all pick option 1. + for option in raw_scores: + salt = ( + int(hashlib.sha256(f"{text}|{option}".encode()).hexdigest(), 16) % 100 + ) + raw_scores[option] += salt / 10_000 + + total = sum(raw_scores.values()) or 1.0 + probabilities = {k: round(v / total, 4) for k, v in raw_scores.items()} + best = max(probabilities, key=probabilities.get) + return ChoiceAnswer( + choice=best, confidence=probabilities[best], probabilities=probabilities + ) + + def _answer_noul(self, text: str) -> NoulAnswer: + urgency_keywords = { + "urgent": 0.5, + "immediately": 0.4, + "asap": 0.5, + "critical": 0.5, + "down": 0.3, + "for 3 days": 0.3, + "for days": 0.3, + "right now": 0.4, + } + return NoulAnswer(noul=round(_keyword_score(text, urgency_keywords), 4)) + + def _answer_score(self, text: str, question: Score) -> ScoreAnswer: + anger_keywords = { + "furious": 1.0, + "angry": 0.8, + "unacceptable": 0.7, + "terrible": 0.6, + "frustrated": 0.5, + "disappointed": 0.4, + "please": -0.1, + "thanks": -0.2, + } + intensity = _keyword_score(text, anger_keywords) + max_level = len(question.criteria) - 1 + score = round(intensity * max_level, 4) + low, high = int(score), min(int(score) + 1, max_level) + frac = score - low + probabilities = {str(i): 0.0 for i in range(len(question.criteria))} + if low == high: + probabilities[str(low)] = 1.0 + else: + probabilities[str(low)] = round(1 - frac, 4) + probabilities[str(high)] = round(frac, 4) + confidence = round(max(probabilities.values()), 4) + return ScoreAnswer( + score=score, confidence=confidence, probabilities=probabilities + ) diff --git a/packages/uipath/samples/ticket-triage-agent/main.py b/packages/uipath/samples/ticket-triage-agent/main.py new file mode 100644 index 000000000..d67e58671 --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/main.py @@ -0,0 +1,215 @@ +"""Two-tier support-ticket triage agent. + +Demonstrates a "System 1 / System 2" pattern: + +1. Tier 1 (System 1) - a fast, cheap, structured-decision model triages the + ticket: which department it belongs to, whether it's urgent, and how + frustrated the customer sounds. This sample stubs that tier with a mock + of TypeSafe AI's "Jev" model (see `jev_client.py` for why it's a stub, + not a real dependency). +2. Tier 2 (System 2) - branches on the triage result: + * High urgency/frustration, or low routing confidence -> escalate to a + human via a UiPath Action Center QuickForm task (HITL), with the + Tier 1 decision attached as context. + * Otherwise -> call a real LLM (UiPath LLM Gateway) to draft a reply + for the routed department and mark the ticket resolved. +""" + +from __future__ import annotations + +import os + +from jev_client import Choice, Noul, Score, TypeSafeClient +from pydantic import BaseModel, Field + +from uipath.platform import UiPath +from uipath.platform.chat import ChatModels +from uipath.tracing import traced + +# --- Tunable escalation thresholds ----------------------------------------- +URGENCY_THRESHOLD = 0.6 +FRUSTRATION_THRESHOLD = 1.0 # out of 2 ("Calm" / "Frustrated" / "Very angry") +ROUTING_CONFIDENCE_THRESHOLD = 0.45 + +# Fixed schema key for the QuickForm task this sample registers/reuses. +TRIAGE_TASK_SCHEMA_KEY = "5b6f7e2a-3c9d-4e11-9a2b-6d1f0c9a2e77" + +DEPARTMENT_SYSTEM_PROMPTS = { + "billing": ( + "You are a billing support agent. Write a short, polite reply that " + "acknowledges the customer's billing issue and explains next steps." + ), + "technical": ( + "You are a technical support agent. Write a short, polite reply " + "acknowledging the technical issue and the troubleshooting steps " + "that will follow." + ), + "sales": ( + "You are a sales representative. Write a short, polite reply " + "addressing the customer's pricing or account question." + ), +} + + +class TicketInput(BaseModel): + """A support ticket to triage.""" + + subject: str = Field(description="Ticket subject line") + message: str = Field(description="Ticket body / customer message") + + +class TriageDecision(BaseModel): + """Tier 1 (Jev) structured triage output.""" + + department: str + department_confidence: float + is_urgent: float + frustration_score: float + + +class TicketOutput(BaseModel): + """Final agent output.""" + + triage: TriageDecision + escalated: bool + auto_reply: str | None = Field( + default=None, description="LLM-drafted reply, when auto-handled" + ) + action_task_id: int | None = Field( + default=None, description="Action Center task id, when escalated" + ) + + +@traced() +def triage_ticket(ticket: TicketInput) -> TriageDecision: + """Run the fast Tier 1 structured triage over the ticket.""" + client = TypeSafeClient() + response = client.system_one( + state={"subject": ticket.subject, "message": ticket.message}, + questions={ + "department": Choice( + instructions="Which team should handle this ticket?", + criteria={ + "billing": "Payments, invoicing, refunds, subscriptions", + "technical": "Bugs, outages, integrations, errors", + "sales": "Pricing, upgrades, new accounts, demos", + }, + ), + "is_urgent": Noul(instructions="Does this convey urgency?"), + "frustration": Score( + instructions="How frustrated does the customer sound?", + criteria=["Calm", "Frustrated", "Very angry"], + ), + }, + ) + department = response.answers["department"] + is_urgent = response.answers["is_urgent"] + frustration = response.answers["frustration"] + return TriageDecision( + department=department.choice, + department_confidence=department.confidence, + is_urgent=is_urgent.noul, + frustration_score=frustration.score, + ) + + +def needs_escalation(triage: TriageDecision) -> bool: + """Decide whether the ticket should go to a human instead of auto-reply.""" + return ( + triage.is_urgent >= URGENCY_THRESHOLD + or triage.frustration_score >= FRUSTRATION_THRESHOLD + or triage.department_confidence < ROUTING_CONFIDENCE_THRESHOLD + ) + + +@traced() +def escalate_to_human( + client: UiPath, ticket: TicketInput, triage: TriageDecision +) -> int: + """Create an Action Center QuickForm task for a human reviewer.""" + schema = { + "id": TRIAGE_TASK_SCHEMA_KEY, + "fields": [ + {"id": "subject", "type": "text", "label": "Subject", "direction": "input"}, + {"id": "message", "type": "text", "label": "Message", "direction": "input"}, + { + "id": "department", + "type": "text", + "label": "Suggested department", + "direction": "input", + }, + { + "id": "urgency", + "type": "text", + "label": "Urgency score", + "direction": "input", + }, + { + "id": "frustration", + "type": "text", + "label": "Frustration score", + "direction": "input", + }, + { + "id": "reply", + "type": "text", + "label": "Reviewer reply", + "direction": "output", + }, + ], + "outcomes": [ + {"id": "resolve", "name": "Resolve", "type": "string", "isPrimary": True}, + ], + } + task = client.tasks.create_quickform( + title=f"Review ticket: {ticket.subject}", + task_schema_key=TRIAGE_TASK_SCHEMA_KEY, + schema=schema, + data={ + "subject": ticket.subject, + "message": ticket.message, + "department": f"{triage.department} ({triage.department_confidence:.0%} confidence)", + "urgency": f"{triage.is_urgent:.2f}", + "frustration": f"{triage.frustration_score:.2f}", + }, + priority="High" if triage.is_urgent >= URGENCY_THRESHOLD else "Medium", + folder_path=os.environ.get("UIPATH_FOLDER_PATH"), + ) + return task.id + + +@traced() +async def draft_auto_reply( + client: UiPath, ticket: TicketInput, triage: TriageDecision +) -> str: + """Use a real LLM to draft a reply for a routine, non-urgent ticket.""" + system_prompt = DEPARTMENT_SYSTEM_PROMPTS.get( + triage.department, DEPARTMENT_SYSTEM_PROMPTS["technical"] + ) + result = await client.llm.chat_completions( + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": f"Subject: {ticket.subject}\n\n{ticket.message}", + }, + ], + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=300, + temperature=0.3, + ) + return result.choices[0].message.content or "" + + +@traced() +async def main(input: TicketInput) -> TicketOutput: + """Triage a ticket and either auto-reply or escalate to a human.""" + triage = triage_ticket(input) + client = UiPath() + + if needs_escalation(triage): + task_id = escalate_to_human(client, input, triage) + return TicketOutput(triage=triage, escalated=True, action_task_id=task_id) + + reply = await draft_auto_reply(client, input, triage) + return TicketOutput(triage=triage, escalated=False, auto_reply=reply) diff --git a/packages/uipath/samples/ticket-triage-agent/pyproject.toml b/packages/uipath/samples/ticket-triage-agent/pyproject.toml new file mode 100644 index 000000000..184fa27bf --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "ticket-triage-agent" +version = "0.0.1" +description = "Two-tier support ticket triage: a mocked System One model (Jev) for fast structured routing, escalating to Action Center HITL or an LLM-drafted auto-reply." +requires-python = ">=3.11" +dependencies = [ + "uipath", +] + +# Once you have independently vetted TypeSafe AI's real SDK, swap the local +# jev_client.py stub for the real package by uncommenting the line below +# and deleting jev_client.py. See jev_client.py for why it isn't wired in +# as a live dependency here. +# "typesafe-sdk", + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/packages/uipath/samples/ticket-triage-agent/uipath.json b/packages/uipath/samples/ticket-triage-agent/uipath.json new file mode 100644 index 000000000..1883d9e1a --- /dev/null +++ b/packages/uipath/samples/ticket-triage-agent/uipath.json @@ -0,0 +1,6 @@ +{ + "functions": { + "main": "main.py:main" + }, + "id": "a68ad7da-71fa-4470-bbea-92b6204ad519" +} \ No newline at end of file From cab8e9e53adbd7201eb89b4e4466a0c3aaea4da6 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Mon, 21 Sep 2026 13:13:23 -0700 Subject: [PATCH 2/5] fix(ticket-triage-agent): use the real typesafe-sdk instead of a mock Statically inspected the typesafe-sdk wheel (no install/execution) before wiring it in: an apparently auto-generated OpenAPI client with no eval/exec/ subprocess calls, no env-var exfiltration, and a single documented API host. Its httpx2 dependency is real and unrelated to TypeSafe AI (also used by the mcp SDK). Pins typesafe-sdk>=0.7.0 and drops the local jev_client.py stub; verified end-to-end against the real Jev API for both the auto-reply and escalation paths. Co-Authored-By: Claude Sonnet 5 --- .../samples/ticket-triage-agent/.env.example | 6 +- .../samples/ticket-triage-agent/README.md | 52 ++--- .../samples/ticket-triage-agent/jev_client.py | 213 ------------------ .../samples/ticket-triage-agent/main.py | 7 +- .../ticket-triage-agent/pyproject.toml | 9 +- 5 files changed, 31 insertions(+), 256 deletions(-) delete mode 100644 packages/uipath/samples/ticket-triage-agent/jev_client.py diff --git a/packages/uipath/samples/ticket-triage-agent/.env.example b/packages/uipath/samples/ticket-triage-agent/.env.example index 3d2bec222..0466f06b3 100644 --- a/packages/uipath/samples/ticket-triage-agent/.env.example +++ b/packages/uipath/samples/ticket-triage-agent/.env.example @@ -5,7 +5,5 @@ UIPATH_ACCESS_TOKEN=your_access_token_here # an Orchestrator folder. UIPATH_FOLDER_PATH=Shared -# Not needed to run this sample: jev_client.py is a local, offline mock and -# reads no environment variables. This would only apply if you swap in the -# real typesafe-sdk package (see README.md) after independently vetting it. -# TYPESAFE_API_KEY=your_typesafe_api_key_here +# Required for Tier 1 triage: API key for TypeSafe AI's Jev model. +TYPESAFE_API_KEY=your_typesafe_api_key_here diff --git a/packages/uipath/samples/ticket-triage-agent/README.md b/packages/uipath/samples/ticket-triage-agent/README.md index adace0319..26839b842 100644 --- a/packages/uipath/samples/ticket-triage-agent/README.md +++ b/packages/uipath/samples/ticket-triage-agent/README.md @@ -6,8 +6,8 @@ This sample demonstrates a two-tier triage pattern for support tickets: takes the ticket text and a set of typed questions, and returns calibrated typed answers (a classification, a boolean, and a score) instead of free text - orders of magnitude cheaper and faster than an - LLM call. This sample stubs that tier with a mock of TypeSafe AI's `Jev` - model. + LLM call. This sample uses TypeSafe AI's `Jev` model via the + `typesafe-sdk` package. 2. **Tier 2 (System 2) - branch on the triage result.** - If the ticket is urgent, the customer sounds frustrated, or the department routing is low-confidence, the agent **escalates to a @@ -20,37 +20,33 @@ This sample demonstrates a two-tier triage pattern for support tickets: ## About the "Jev" model (important - read this) -**`jev_client.py` in this sample is a local mock, not a real UiPath or -TypeSafe AI integration.** TypeSafe AI's real `typesafe-sdk` PyPI package -was published the same day this sample was written, already has several -releases, and lists an unusual dependency (`httpx2` instead of `httpx`). -Rather than pull an unverified, very-recently-published third-party package -into this SDK's samples, `jev_client.py` hand-rolls a small deterministic -stand-in that mirrors the real SDK's documented public shape: +This sample calls TypeSafe AI's real `typesafe-sdk` PyPI package, not a +mock. `typesafe-sdk` is a very recently published package (it went out the +same day this sample was first written, with several releases in one day), +so before wiring it in we statically inspected the wheel's source (no +install/execution): it's a normal, apparently auto-generated API client +(the response schemas reference an OpenAPI spec) with no `eval`/`exec`/ +`subprocess` calls, no exfiltration of environment variables, and a single +documented API host (`api.typesafe.ai`). Its `httpx2` dependency is a real, +independent package (also used by the `mcp` SDK) unrelated to TypeSafe AI. +If you're pulling this into your own project, do your own review before +trusting a same-day release. ```python from typesafe_sdk import Choice, Noul, Score, TypeSafeClient -client = TypeSafeClient() +client = TypeSafeClient() # reads TYPESAFE_API_KEY from the environment response = client.system_one(state=..., questions={...}) ``` -The mock scores keyword signals in the ticket text instead of calling any -external model, so this sample runs fully offline. If you want to use the -real `typesafe-sdk` package, **independently vet it first**, then swap the -import in `main.py` (`from jev_client import ...` -> `from typesafe_sdk -import ...`) and add it to `pyproject.toml` - the public shape used here -(`TypeSafeClient`, `Choice`/`Noul`/`Score`, `.system_one(...)`) is designed -to match the real SDK, so no other code should need to change. - -**No API key is needed to run this sample.** `jev_client.py` is a local, -offline mock and reads no environment variables. `TYPESAFE_API_KEY` (in -`.env.example`, commented out) would only be needed if you later swap in -the real `typesafe-sdk` package, after independently vetting it. +`pyproject.toml` pins `typesafe-sdk>=0.7.0` rather than leaving it +unbounded, since `uipath`'s own `uv` install already applies a +minimum-package-age safety check that skips the very newest release. ## Prerequisites * [UV package manager](https://docs.astral.sh/uv/) installed +* A TypeSafe AI API key (`TYPESAFE_API_KEY`) for the Tier 1 triage step * A UiPath Orchestrator tenant with: * Access to the LLM Gateway (for the auto-reply path) * Access to Action Center (for the escalation path) @@ -72,8 +68,8 @@ uv sync ### Step 3: Configure credentials -Copy `.env.example` to `.env` and fill in your Orchestrator URL and access -token: +Copy `.env.example` to `.env` and fill in your Orchestrator URL, access +token, and TypeSafe AI API key: ```bash cp .env.example .env @@ -101,10 +97,10 @@ Try editing `input.json` to see both branches: ## How it works -1. `triage_ticket` sends the ticket's subject/message to the mock `Jev` - client as `state`, along with three typed `questions` - (`department: Choice`, `is_urgent: Noul`, `frustration: Score`), and gets - back a `TriageDecision`. +1. `triage_ticket` sends the ticket's subject/message to `Jev` (via + `TypeSafeClient.system_one`) as `state`, along with three typed + `questions` (`department: Choice`, `is_urgent: Noul`, + `frustration: Score`), and gets back a `TriageDecision`. 2. `needs_escalation` checks the triage output against fixed thresholds (urgency, frustration, routing confidence) in `main.py`. 3. On escalation, `escalate_to_human` creates an Action Center QuickForm diff --git a/packages/uipath/samples/ticket-triage-agent/jev_client.py b/packages/uipath/samples/ticket-triage-agent/jev_client.py deleted file mode 100644 index 627fb5ef2..000000000 --- a/packages/uipath/samples/ticket-triage-agent/jev_client.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Local stub for TypeSafe AI's "Jev" System One model client. - -*** THIS IS A MOCK, NOT A REAL UIPATH OR TYPESAFE AI PRODUCT INTEGRATION. *** - -TypeSafe AI's `typesafe-sdk` PyPI package was published the same day this -sample was written, has an unusual dependency (`httpx2`), and has not been -independently vetted. Rather than pull an unverified, brand-new third-party -package into this SDK's samples, this module hand-rolls a small stand-in -that mimics the real SDK's public shape: - - from typesafe_sdk import Choice, Noul, Score, TypeSafeClient - - client = TypeSafeClient() - response = client.system_one(state=..., questions={...}) - -Once you have independently vetted the real `typesafe-sdk` package (or any -other System One provider), swapping it in is a one-line change: replace the -import below with the real package's import and delete this file. Nothing -else in `main.py` needs to change, since the public shape (`TypeSafeClient`, -`Choice`/`Noul`/`Score`, `.system_one(...)`) is mirrored here on purpose. -""" - -from __future__ import annotations - -import hashlib -from dataclasses import dataclass -from typing import Any - - -@dataclass -class Choice: - """Declares a multi-option classification question.""" - - instructions: str - criteria: dict[str, str] - - -@dataclass -class Noul: - """Declares a boolean (0-1 probability) question.""" - - instructions: str - criteria: dict[str, str] | None = None - - -@dataclass -class Score: - """Declares a ranked-scale question.""" - - instructions: str - criteria: list[str] - - -@dataclass -class ChoiceAnswer: - """Typed answer to a `Choice` question.""" - - choice: str - confidence: float - probabilities: dict[str, float] - - -@dataclass -class NoulAnswer: - """Typed answer to a `Noul` question.""" - - noul: float - - -@dataclass -class ScoreAnswer: - """Typed answer to a `Score` question.""" - - score: float - confidence: float - probabilities: dict[str, float] - - -@dataclass -class SystemOneResponse: - """Mirrors the real SDK's response envelope.""" - - model: str - answers: dict[str, ChoiceAnswer | NoulAnswer | ScoreAnswer] - usage: dict[str, int] - - -def _keyword_score(text: str, keywords: dict[str, float]) -> float: - """Deterministically scores text against a keyword table (0-1).""" - lowered = text.lower() - score = sum(weight for keyword, weight in keywords.items() if keyword in lowered) - return max(0.0, min(1.0, score)) - - -class TypeSafeClient: - """Mock stand-in for `typesafe_sdk.TypeSafeClient`. - - Simulates a fast, deterministic "System One" model: instead of an LLM - call, it scores keyword signals in the ticket text to produce calibrated - typed answers, matching the shape the real API documents (see - https://developers.cloudflare.com/ai/models/typesafe/jev/). - """ - - def __init__(self, model: str = "jev-1.13.0-mock") -> None: - self.model = model - - def system_one( - self, *, state: dict[str, Any], questions: dict[str, Choice | Noul | Score] - ) -> SystemOneResponse: - text = " ".join( - str(value) for key in ("subject", "message") if (value := state.get(key)) - ) - - answers: dict[str, ChoiceAnswer | NoulAnswer | ScoreAnswer] = {} - for name, question in questions.items(): - if isinstance(question, Choice): - answers[name] = self._answer_choice(text, question) - elif isinstance(question, Noul): - answers[name] = self._answer_noul(text) - elif isinstance(question, Score): - answers[name] = self._answer_score(text, question) - else: # pragma: no cover - defensive - raise TypeError(f"Unsupported question type for {name!r}: {question!r}") - - input_tokens = len(text.split()) * 2 + 40 - return SystemOneResponse( - model=self.model, - answers=answers, - usage={"input_tokens": input_tokens, "output_tokens": len(answers) * 12}, - ) - - def _answer_choice(self, text: str, question: Choice) -> ChoiceAnswer: - department_keywords = { - "billing": { - "charge": 0.6, - "refund": 0.6, - "invoice": 0.6, - "payment": 0.5, - "subscription": 0.4, - }, - "technical": { - "error": 0.6, - "bug": 0.6, - "crash": 0.6, - "down": 0.5, - "not working": 0.5, - "integration": 0.4, - }, - "sales": { - "pricing": 0.6, - "upgrade": 0.6, - "plan": 0.4, - "demo": 0.5, - "quote": 0.5, - }, - } - raw_scores = { - option: _keyword_score(text, department_keywords.get(option, {})) - for option in question.criteria - } - # Deterministic tie-break salt so identical zero-scores don't all pick option 1. - for option in raw_scores: - salt = ( - int(hashlib.sha256(f"{text}|{option}".encode()).hexdigest(), 16) % 100 - ) - raw_scores[option] += salt / 10_000 - - total = sum(raw_scores.values()) or 1.0 - probabilities = {k: round(v / total, 4) for k, v in raw_scores.items()} - best = max(probabilities, key=probabilities.get) - return ChoiceAnswer( - choice=best, confidence=probabilities[best], probabilities=probabilities - ) - - def _answer_noul(self, text: str) -> NoulAnswer: - urgency_keywords = { - "urgent": 0.5, - "immediately": 0.4, - "asap": 0.5, - "critical": 0.5, - "down": 0.3, - "for 3 days": 0.3, - "for days": 0.3, - "right now": 0.4, - } - return NoulAnswer(noul=round(_keyword_score(text, urgency_keywords), 4)) - - def _answer_score(self, text: str, question: Score) -> ScoreAnswer: - anger_keywords = { - "furious": 1.0, - "angry": 0.8, - "unacceptable": 0.7, - "terrible": 0.6, - "frustrated": 0.5, - "disappointed": 0.4, - "please": -0.1, - "thanks": -0.2, - } - intensity = _keyword_score(text, anger_keywords) - max_level = len(question.criteria) - 1 - score = round(intensity * max_level, 4) - low, high = int(score), min(int(score) + 1, max_level) - frac = score - low - probabilities = {str(i): 0.0 for i in range(len(question.criteria))} - if low == high: - probabilities[str(low)] = 1.0 - else: - probabilities[str(low)] = round(1 - frac, 4) - probabilities[str(high)] = round(frac, 4) - confidence = round(max(probabilities.values()), 4) - return ScoreAnswer( - score=score, confidence=confidence, probabilities=probabilities - ) diff --git a/packages/uipath/samples/ticket-triage-agent/main.py b/packages/uipath/samples/ticket-triage-agent/main.py index d67e58671..e75b11025 100644 --- a/packages/uipath/samples/ticket-triage-agent/main.py +++ b/packages/uipath/samples/ticket-triage-agent/main.py @@ -4,9 +4,8 @@ 1. Tier 1 (System 1) - a fast, cheap, structured-decision model triages the ticket: which department it belongs to, whether it's urgent, and how - frustrated the customer sounds. This sample stubs that tier with a mock - of TypeSafe AI's "Jev" model (see `jev_client.py` for why it's a stub, - not a real dependency). + frustrated the customer sounds. Uses TypeSafe AI's `typesafe-sdk` and + its "Jev" System One model (requires TYPESAFE_API_KEY; see README.md). 2. Tier 2 (System 2) - branches on the triage result: * High urgency/frustration, or low routing confidence -> escalate to a human via a UiPath Action Center QuickForm task (HITL), with the @@ -19,8 +18,8 @@ import os -from jev_client import Choice, Noul, Score, TypeSafeClient from pydantic import BaseModel, Field +from typesafe_sdk import Choice, Noul, Score, TypeSafeClient from uipath.platform import UiPath from uipath.platform.chat import ChatModels diff --git a/packages/uipath/samples/ticket-triage-agent/pyproject.toml b/packages/uipath/samples/ticket-triage-agent/pyproject.toml index 184fa27bf..97296a704 100644 --- a/packages/uipath/samples/ticket-triage-agent/pyproject.toml +++ b/packages/uipath/samples/ticket-triage-agent/pyproject.toml @@ -1,18 +1,13 @@ [project] name = "ticket-triage-agent" version = "0.0.1" -description = "Two-tier support ticket triage: a mocked System One model (Jev) for fast structured routing, escalating to Action Center HITL or an LLM-drafted auto-reply." +description = "Two-tier support ticket triage: TypeSafe AI's Jev System One model for fast structured routing, escalating to Action Center HITL or an LLM-drafted auto-reply." requires-python = ">=3.11" dependencies = [ "uipath", + "typesafe-sdk>=0.7.0", ] -# Once you have independently vetted TypeSafe AI's real SDK, swap the local -# jev_client.py stub for the real package by uncommenting the line below -# and deleting jev_client.py. See jev_client.py for why it isn't wired in -# as a live dependency here. -# "typesafe-sdk", - [dependency-groups] dev = [ "uipath-dev", From cdd816ae07f8f902b5992d375eead3eba9c6570c Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Mon, 21 Sep 2026 13:16:09 -0700 Subject: [PATCH 3/5] fix(ticket-triage-agent): fail fast on missing folder/task id, fix wording Addresses Copilot PR review: escalate_to_human now raises a clear RuntimeError when UIPATH_FOLDER_PATH is unset (previously silently omitted the folder, causing a hard-to-diagnose Orchestrator 400) and when Action Center returns no task id (Task.id is Optional[int], but this function promises -> int). Also clarifies the README: Jev's urgency answer is a calibrated 0-1 probability, not a boolean. Co-Authored-By: Claude Sonnet 5 --- .../uipath/samples/ticket-triage-agent/README.md | 4 ++-- .../uipath/samples/ticket-triage-agent/main.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/uipath/samples/ticket-triage-agent/README.md b/packages/uipath/samples/ticket-triage-agent/README.md index 26839b842..4660794a0 100644 --- a/packages/uipath/samples/ticket-triage-agent/README.md +++ b/packages/uipath/samples/ticket-triage-agent/README.md @@ -4,8 +4,8 @@ This sample demonstrates a two-tier triage pattern for support tickets: 1. **Tier 1 (System 1) - fast structured triage.** A "System One" model takes the ticket text and a set of typed questions, and returns - calibrated typed answers (a classification, a boolean, and a score) - instead of free text - orders of magnitude cheaper and faster than an + calibrated typed answers (a classification, a 0-1 probability, and a + score) instead of free text - orders of magnitude cheaper and faster than an LLM call. This sample uses TypeSafe AI's `Jev` model via the `typesafe-sdk` package. 2. **Tier 2 (System 2) - branch on the triage result.** diff --git a/packages/uipath/samples/ticket-triage-agent/main.py b/packages/uipath/samples/ticket-triage-agent/main.py index e75b11025..6258fe6b7 100644 --- a/packages/uipath/samples/ticket-triage-agent/main.py +++ b/packages/uipath/samples/ticket-triage-agent/main.py @@ -126,6 +126,14 @@ def escalate_to_human( client: UiPath, ticket: TicketInput, triage: TriageDecision ) -> int: """Create an Action Center QuickForm task for a human reviewer.""" + folder_path = os.environ.get("UIPATH_FOLDER_PATH", "").strip() + if not folder_path: + raise RuntimeError( + "UIPATH_FOLDER_PATH is not set. Action Center tasks must be " + "created in an Orchestrator folder; set it in .env (see " + ".env.example)." + ) + schema = { "id": TRIAGE_TASK_SCHEMA_KEY, "fields": [ @@ -172,8 +180,12 @@ def escalate_to_human( "frustration": f"{triage.frustration_score:.2f}", }, priority="High" if triage.is_urgent >= URGENCY_THRESHOLD else "Medium", - folder_path=os.environ.get("UIPATH_FOLDER_PATH"), + folder_path=folder_path, ) + if task.id is None: + raise RuntimeError( + "Action Center did not return a task id for the created task." + ) return task.id From ae3dd7414033467182045cb51b7a554ee17d737a Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Mon, 21 Sep 2026 13:39:38 -0700 Subject: [PATCH 4/5] fix(ticket-triage-agent): run blocking triage/escalation off the event loop main is async but was calling the synchronous, network-bound triage_ticket and escalate_to_human directly, blocking the event loop during those calls. Both now run via asyncio.to_thread. Also reclassifies uipath.json's entry point from "functions" to "agents", matching this sample's LLM/HITL nature rather than a deterministic function. Addresses Copilot PR review feedback; verified end-to-end that the Jev call and Action Center escalation still complete correctly. Co-Authored-By: Claude Sonnet 5 --- packages/uipath/samples/ticket-triage-agent/main.py | 5 +++-- packages/uipath/samples/ticket-triage-agent/uipath.json | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/uipath/samples/ticket-triage-agent/main.py b/packages/uipath/samples/ticket-triage-agent/main.py index 6258fe6b7..d13d409c4 100644 --- a/packages/uipath/samples/ticket-triage-agent/main.py +++ b/packages/uipath/samples/ticket-triage-agent/main.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import os from pydantic import BaseModel, Field @@ -215,11 +216,11 @@ async def draft_auto_reply( @traced() async def main(input: TicketInput) -> TicketOutput: """Triage a ticket and either auto-reply or escalate to a human.""" - triage = triage_ticket(input) + triage = await asyncio.to_thread(triage_ticket, input) client = UiPath() if needs_escalation(triage): - task_id = escalate_to_human(client, input, triage) + task_id = await asyncio.to_thread(escalate_to_human, client, input, triage) return TicketOutput(triage=triage, escalated=True, action_task_id=task_id) reply = await draft_auto_reply(client, input, triage) diff --git a/packages/uipath/samples/ticket-triage-agent/uipath.json b/packages/uipath/samples/ticket-triage-agent/uipath.json index 1883d9e1a..a3c4a2d3e 100644 --- a/packages/uipath/samples/ticket-triage-agent/uipath.json +++ b/packages/uipath/samples/ticket-triage-agent/uipath.json @@ -1,6 +1,6 @@ { - "functions": { + "agents": { "main": "main.py:main" }, "id": "a68ad7da-71fa-4470-bbea-92b6204ad519" -} \ No newline at end of file +} From 2453cffa52a443e0bea5a0d42a0df9f6fc0bb087 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Mon, 21 Sep 2026 13:59:00 -0700 Subject: [PATCH 5/5] fix(ticket-triage-agent): address remaining review findings - Fail loud instead of silently auto-resolving a ticket with an empty reply: draft_auto_reply now raises when the LLM Gateway returns no choices or empty content, instead of returning "". - Remove uipath.json's hardcoded project id: 21 of 23 samples ship none, and a committed id makes cli_pack.py use it as every reader's projectId (collision across every copy of this sample). uipath init mints a per-user id on step 4 instead. Keeps the "agents" entry-point key. - Rename TriageDecision.is_urgent -> urgency (the field held a 0-1 probability, not a boolean; the Jev question key "is_urgent" is unaffected) and derive the frustration scale from the response instead of hardcoding "out of 2", so escalation logic stays correct if the Score criteria list is ever edited. Added Field bounds. - Hoist the QuickForm task schema to a module-level constant instead of rebuilding it on every escalate_to_human call. - Correct the README's due-diligence claims about typesafe-sdk: PyPI shows its first release on 2026-09-09, not "the same day this sample was written", and httpx2 is an unrelated, independent package (the pydantic org's next-gen httpx), not a red flag specific to TypeSafe AI. - Document that every uipath eval case now needs TYPESAFE_API_KEY too, and that escalating cases create real, uncleaned Action Center tasks. Verified locally: the real Jev call and triage still succeed end-to-end with the renamed/validated fields (blocked further downstream only by an expired UIPATH_ACCESS_TOKEN, unrelated to these changes). Co-Authored-By: Claude Sonnet 5 --- .../samples/ticket-triage-agent/README.md | 30 +++-- .../samples/ticket-triage-agent/main.py | 106 ++++++++++-------- .../samples/ticket-triage-agent/uipath.json | 5 +- 3 files changed, 81 insertions(+), 60 deletions(-) diff --git a/packages/uipath/samples/ticket-triage-agent/README.md b/packages/uipath/samples/ticket-triage-agent/README.md index 4660794a0..309378f3a 100644 --- a/packages/uipath/samples/ticket-triage-agent/README.md +++ b/packages/uipath/samples/ticket-triage-agent/README.md @@ -21,16 +21,16 @@ This sample demonstrates a two-tier triage pattern for support tickets: ## About the "Jev" model (important - read this) This sample calls TypeSafe AI's real `typesafe-sdk` PyPI package, not a -mock. `typesafe-sdk` is a very recently published package (it went out the -same day this sample was first written, with several releases in one day), -so before wiring it in we statically inspected the wheel's source (no +mock. `typesafe-sdk` is a young package (first published 2026-09-09), so +before wiring it in we statically inspected the wheel's source (no install/execution): it's a normal, apparently auto-generated API client (the response schemas reference an OpenAPI spec) with no `eval`/`exec`/ -`subprocess` calls, no exfiltration of environment variables, and a single -documented API host (`api.typesafe.ai`). Its `httpx2` dependency is a real, -independent package (also used by the `mcp` SDK) unrelated to TypeSafe AI. -If you're pulling this into your own project, do your own review before -trusting a same-day release. +`subprocess` calls, and no exfiltration of environment variables. Its +`httpx2` dependency turned out to be an unrelated, independent package +(the pydantic org's next-generation `httpx`), not something specific to +TypeSafe AI. If you're pulling a new AI SDK into your own project, reading +the source before trusting it is a cheap, worthwhile step regardless of how +long the package has existed. ```python from typesafe_sdk import Choice, Noul, Score, TypeSafeClient @@ -129,9 +129,17 @@ Two cases (`sales-demo-request-wrong-department`, mirroring the pattern in `classification_agent`, to demonstrate the evaluators catching a mismatch. -Since `draft_auto_reply` and `escalate_to_human` call real UiPath services -(LLM Gateway, Action Center), running the full eval set end-to-end requires -valid credentials in `.env`: +Every case calls the real Jev API (`TYPESAFE_API_KEY`), and Tier 2 always +runs too: `draft_auto_reply` and `escalate_to_human` call real UiPath +services (LLM Gateway, Action Center), so the full eval set requires valid +credentials in `.env` for all 11 cases, not just the ones the two +evaluators grade. + +**Heads up:** none of the current evaluators grade `auto_reply` or +`action_task_id`, but the ~3 cases whose triage escalates still create a +real Action Center QuickForm task each run, and nothing in this sample +cleans them up. Expect leftover tasks in Action Center after repeated +`uipath eval` runs. ```bash uipath eval diff --git a/packages/uipath/samples/ticket-triage-agent/main.py b/packages/uipath/samples/ticket-triage-agent/main.py index d13d409c4..080c6f881 100644 --- a/packages/uipath/samples/ticket-triage-agent/main.py +++ b/packages/uipath/samples/ticket-triage-agent/main.py @@ -28,12 +28,47 @@ # --- Tunable escalation thresholds ----------------------------------------- URGENCY_THRESHOLD = 0.6 -FRUSTRATION_THRESHOLD = 1.0 # out of 2 ("Calm" / "Frustrated" / "Very angry") +FRUSTRATION_THRESHOLD_FRACTION = 0.5 # fraction of the frustration scale ROUTING_CONFIDENCE_THRESHOLD = 0.45 # Fixed schema key for the QuickForm task this sample registers/reuses. TRIAGE_TASK_SCHEMA_KEY = "5b6f7e2a-3c9d-4e11-9a2b-6d1f0c9a2e77" +TRIAGE_TASK_SCHEMA = { + "id": TRIAGE_TASK_SCHEMA_KEY, + "fields": [ + {"id": "subject", "type": "text", "label": "Subject", "direction": "input"}, + {"id": "message", "type": "text", "label": "Message", "direction": "input"}, + { + "id": "department", + "type": "text", + "label": "Suggested department", + "direction": "input", + }, + { + "id": "urgency", + "type": "text", + "label": "Urgency score", + "direction": "input", + }, + { + "id": "frustration", + "type": "text", + "label": "Frustration score", + "direction": "input", + }, + { + "id": "reply", + "type": "text", + "label": "Reviewer reply", + "direction": "output", + }, + ], + "outcomes": [ + {"id": "resolve", "name": "Resolve", "type": "string", "isPrimary": True}, + ], +} + DEPARTMENT_SYSTEM_PROMPTS = { "billing": ( "You are a billing support agent. Write a short, polite reply that " @@ -62,9 +97,16 @@ class TriageDecision(BaseModel): """Tier 1 (Jev) structured triage output.""" department: str - department_confidence: float - is_urgent: float - frustration_score: float + department_confidence: float = Field(ge=0.0, le=1.0) + urgency: float = Field( + ge=0.0, le=1.0, description="Calibrated probability that the ticket is urgent" + ) + frustration_score: float = Field( + ge=0.0, description="Rubric-weighted frustration level, 0..frustration_scale" + ) + frustration_scale: int = Field( + ge=1, description="Maximum value frustration_score can take" + ) class TicketOutput(BaseModel): @@ -108,16 +150,18 @@ def triage_ticket(ticket: TicketInput) -> TriageDecision: return TriageDecision( department=department.choice, department_confidence=department.confidence, - is_urgent=is_urgent.noul, + urgency=is_urgent.noul, frustration_score=frustration.score, + frustration_scale=len(frustration.probabilities) - 1, ) def needs_escalation(triage: TriageDecision) -> bool: """Decide whether the ticket should go to a human instead of auto-reply.""" return ( - triage.is_urgent >= URGENCY_THRESHOLD - or triage.frustration_score >= FRUSTRATION_THRESHOLD + triage.urgency >= URGENCY_THRESHOLD + or (triage.frustration_score / triage.frustration_scale) + >= FRUSTRATION_THRESHOLD_FRACTION or triage.department_confidence < ROUTING_CONFIDENCE_THRESHOLD ) @@ -135,52 +179,18 @@ def escalate_to_human( ".env.example)." ) - schema = { - "id": TRIAGE_TASK_SCHEMA_KEY, - "fields": [ - {"id": "subject", "type": "text", "label": "Subject", "direction": "input"}, - {"id": "message", "type": "text", "label": "Message", "direction": "input"}, - { - "id": "department", - "type": "text", - "label": "Suggested department", - "direction": "input", - }, - { - "id": "urgency", - "type": "text", - "label": "Urgency score", - "direction": "input", - }, - { - "id": "frustration", - "type": "text", - "label": "Frustration score", - "direction": "input", - }, - { - "id": "reply", - "type": "text", - "label": "Reviewer reply", - "direction": "output", - }, - ], - "outcomes": [ - {"id": "resolve", "name": "Resolve", "type": "string", "isPrimary": True}, - ], - } task = client.tasks.create_quickform( title=f"Review ticket: {ticket.subject}", task_schema_key=TRIAGE_TASK_SCHEMA_KEY, - schema=schema, + schema=TRIAGE_TASK_SCHEMA, data={ "subject": ticket.subject, "message": ticket.message, "department": f"{triage.department} ({triage.department_confidence:.0%} confidence)", - "urgency": f"{triage.is_urgent:.2f}", - "frustration": f"{triage.frustration_score:.2f}", + "urgency": f"{triage.urgency:.2f}", + "frustration": f"{triage.frustration_score:.2f} / {triage.frustration_scale}", }, - priority="High" if triage.is_urgent >= URGENCY_THRESHOLD else "Medium", + priority="High" if triage.urgency >= URGENCY_THRESHOLD else "Medium", folder_path=folder_path, ) if task.id is None: @@ -210,7 +220,11 @@ async def draft_auto_reply( max_tokens=300, temperature=0.3, ) - return result.choices[0].message.content or "" + if not result.choices or not (content := result.choices[0].message.content): + raise RuntimeError( + "LLM Gateway returned no reply text; refusing to auto-resolve this ticket." + ) + return content @traced() diff --git a/packages/uipath/samples/ticket-triage-agent/uipath.json b/packages/uipath/samples/ticket-triage-agent/uipath.json index a3c4a2d3e..63268db8b 100644 --- a/packages/uipath/samples/ticket-triage-agent/uipath.json +++ b/packages/uipath/samples/ticket-triage-agent/uipath.json @@ -1,6 +1,5 @@ { "agents": { "main": "main.py:main" - }, - "id": "a68ad7da-71fa-4470-bbea-92b6204ad519" -} + } +} \ No newline at end of file