diff --git a/samples/ticket-classification/README.md b/samples/ticket-classification/README.md index d4b84e38..e7bdd04d 100644 --- a/samples/ticket-classification/README.md +++ b/samples/ticket-classification/README.md @@ -1,6 +1,6 @@ # Support Ticket Classification System -Use LangGraph with Azure OpenAI to automatically classify support tickets into predefined categories with confidence scores. UiPath Orchestrator API integration for human approval step. +Use LangGraph with Azure OpenAI to automatically classify support tickets into predefined categories with confidence scores. UiPath Action Center integration for human approval step. ## Debug @@ -52,7 +52,8 @@ The input ticket should be in the following format: ```json { "message": "The ticket message or description", - "ticket_id": "Unique ticket identifier" + "ticket_id": "Unique ticket identifier", + "assignee"[optional]: "username or email of the person assigned to handle escalations" } ``` diff --git a/samples/ticket-classification/agent.mermaid b/samples/ticket-classification/agent.mermaid index 53e6c730..c141ca69 100644 --- a/samples/ticket-classification/agent.mermaid +++ b/samples/ticket-classification/agent.mermaid @@ -1,16 +1,21 @@ -%%{init: {'flowchart': {'curve': 'linear'}}}%% +--- +config: + flowchart: + curve: linear +--- graph TD; __start__([

__start__

]):::first + prepare_input(prepare_input) classify(classify) - create_action(create_action) - human_approval(human_approval) + human_approval_node(human_approval_node) notify_team(notify_team) __end__([

__end__

]):::last - __start__ --> classify; - classify --> create_action; - create_action --> human_approval; - human_approval --> notify_team; + __start__ --> prepare_input; + classify --> human_approval_node; notify_team --> __end__; + prepare_input --> classify; + human_approval_node -.-> classify; + human_approval_node -.-> notify_team; classDef default fill:#f2f0ff,line-height:1.2 classDef first fill-opacity:0 classDef last fill:#bfb6fc diff --git a/samples/ticket-classification/escalation_app_solution/generic-escalation-app-solution-1.0.0.zip b/samples/ticket-classification/escalation_app_solution/generic-escalation-app-solution-1.0.0.zip new file mode 100644 index 00000000..2b1bd169 Binary files /dev/null and b/samples/ticket-classification/escalation_app_solution/generic-escalation-app-solution-1.0.0.zip differ diff --git a/samples/ticket-classification/main.py b/samples/ticket-classification/main.py index 941592a7..1d5738ce 100644 --- a/samples/ticket-classification/main.py +++ b/samples/ticket-classification/main.py @@ -1,16 +1,17 @@ import logging import os -from typing import Literal, Optional +from typing import Literal, Optional, List +from langchain_core.messages import HumanMessage, SystemMessage from langchain_openai import AzureChatOpenAI from langchain_core.output_parsers import PydanticOutputParser -from langchain_core.prompts import ChatPromptTemplate -from langgraph.graph import START, END, StateGraph +from langgraph.graph import START, END, StateGraph, MessagesState from langgraph.types import interrupt, Command from pydantic import BaseModel, Field from uipath import UiPath +from uipath.models import CreateAction logger = logging.getLogger(__name__) uipath = UiPath() @@ -18,17 +19,20 @@ class GraphInput(BaseModel): message: str ticket_id: str + assignee: Optional[str] = None class GraphOutput(BaseModel): label: str confidence: float -class GraphState(BaseModel): +class GraphState(MessagesState): message: str ticket_id: str + assignee: Optional[str] label: Optional[str] = None confidence: Optional[float] = None - + last_predicted_category: Optional[str] + human_approval: Optional[bool] = None class TicketClassification(BaseModel): label: Literal["security", "error", "system", "billing", "performance"] = Field( @@ -40,12 +44,7 @@ class TicketClassification(BaseModel): output_parser = PydanticOutputParser(pydantic_object=TicketClassification) - -prompt = ChatPromptTemplate.from_messages( - [ - ( - "system", - """You are a support ticket classifier. Classify tickets into exactly one category and provide a confidence score. +system_message = """You are a support ticket classifier. Classify tickets into exactly one category and provide a confidence score. {format_instructions} @@ -56,12 +55,20 @@ class TicketClassification(BaseModel): - billing: Payment and subscription related issues - performance: Speed and resource usage concerns -Respond with the classification in the requested JSON format.""", - ), - ("user", "{ticket_text}"), - ] -) - +Respond with the classification in the requested JSON format.""" + +def prepare_input(graph_input: GraphInput) -> GraphState: + return GraphState( + message=graph_input.message, + ticket_id=graph_input.ticket_id, + assignee=graph_input.assignee, + messages=[ + SystemMessage(content=system_message.format(format_instructions=output_parser.get_format_instructions())), + HumanMessage(content=graph_input.message) # Add the initial human message + ], + last_predicted_category=None, + human_approval=None, + ) def get_azure_openai_api_key() -> str: """Get Azure OpenAI API key from environment or UiPath.""" @@ -78,8 +85,13 @@ def get_azure_openai_api_key() -> str: return api_key +def decide_next_node(state: GraphState) -> Literal["classify", "notify_team"]: + if state["human_approval"] is True: + return "notify_team" + + return "classify" -async def classify(state: GraphState) -> GraphState: +async def classify(state: GraphState) -> Command: """Classify the support ticket using LLM.""" llm = AzureChatOpenAI( azure_deployment="gpt-4o-mini", @@ -87,51 +99,77 @@ async def classify(state: GraphState) -> GraphState: azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"), api_version="2024-10-21" ) - _prompt = prompt.partial( - format_instructions=output_parser.get_format_instructions() - ) - chain = _prompt | llm | output_parser + + if state.get("last_predicted_category", None): + predicted_category = state["last_predicted_category"] + state["messages"].append(HumanMessage(content=f"The ticket is 100% not part of the category '{predicted_category}'. Choose another one.")) + chain = llm | output_parser try: - result = await chain.ainvoke({"ticket_text": state.message}) - print(result) - state.label = result.label - state.confidence = result.confidence + result = await chain.ainvoke(state["messages"]) logger.info( f"Ticket classified with label: {result.label} confidence score: {result.confidence}" ) - return state + return Command( + update={ + "confidence": result.confidence, + "label": result.label, + "last_predicted_category": result.label, + "messages": state["messages"], + } + ) except Exception as e: logger.error(f"Classification failed: {str(e)}") - state.label = "error" - state.confidence = 0.0 - return state + return Command( + update={ + "label": "error", + "confidence": "0.0", + } + ) -async def wait_for_human(state: GraphState) -> GraphState: +async def wait_for_human(state: GraphState) -> Command: logger.info("Wait for human approval") - feedback = interrupt(f"Label: {state.label} Confidence: {state.confidence}") - - if isinstance(feedback, bool) and feedback is True: - return Command(goto="notify_team") - else: - return Command(goto=END) + ticket_id = state["ticket_id"] + ticket_message = state["messages"][1].content + label = state["label"] + confidence = state["confidence"] + action_data = interrupt(CreateAction(name="escalation_agent_app", + title="Action Required: Review classification", + data={ + "AgentOutput": ( + f"This is how I classified the ticket: '{ticket_id}'," + f" with message '{ticket_message}' \n" + f"Label: '{label}'" + f" Confidence: '{confidence}'" + ), + "AgentName": "ticket-classification "}, + app_version=1, + assignee=state.get("assignee", None), + )) + + return Command( + update={ + "human_approval": isinstance(action_data["Answer"], bool) and action_data["Answer"] is True + } + ) -async def notify_team(state: GraphState) -> GraphState: +async def notify_team(state: GraphState) -> GraphOutput: logger.info("Send team email notification") - print(state) - return state + return GraphOutput(label=state["label"], confidence=state["confidence"]) """Process a support ticket through the workflow.""" builder = StateGraph(GraphState, input=GraphInput, output=GraphOutput) +builder.add_node("prepare_input", prepare_input) builder.add_node("classify", classify) -builder.add_node("human_approval", wait_for_human) +builder.add_node("human_approval_node", wait_for_human) builder.add_node("notify_team", notify_team) -builder.add_edge(START, "classify") -builder.add_edge("classify", "human_approval") -builder.add_edge("human_approval", "notify_team") +builder.add_edge(START, "prepare_input") +builder.add_edge("prepare_input", "classify") +builder.add_edge("classify", "human_approval_node") +builder.add_conditional_edges("human_approval_node", decide_next_node) builder.add_edge("notify_team", END) diff --git a/samples/ticket-classification/uipath.json b/samples/ticket-classification/uipath.json index ef5601c5..f440fc9b 100644 --- a/samples/ticket-classification/uipath.json +++ b/samples/ticket-classification/uipath.json @@ -2,12 +2,20 @@ "entryPoints": [ { "filePath": "agent", - "uniqueId": "59a8e85c-cec2-414c-9201-3f6b076e05dd", + "uniqueId": "525ea50d-c54f-4185-94e2-501fe9ab341a", "type": "agent", "input": { "type": "object", "properties": { "message": { + "title": "Message", + "type": "string" + }, + "ticket_id": { + "title": "Ticket Id", + "type": "string" + }, + "assignee": { "anyOf": [ { "type": "string" @@ -16,11 +24,8 @@ "type": "null" } ], - "title": "Message" - }, - "ticket_id": { - "title": "Ticket Id", - "type": "string" + "default": null, + "title": "Assignee" } }, "required": [