|
| 1 | +import os |
| 2 | +from datetime import timedelta |
| 3 | +from typing import Any |
| 4 | + |
| 5 | +from pydantic import BaseModel |
| 6 | + |
| 7 | +from hatchet_sdk import ( |
| 8 | + Context, |
| 9 | + DurableContext, |
| 10 | + Hatchet, |
| 11 | + SleepCondition, |
| 12 | + UserEventCondition, |
| 13 | + or_, |
| 14 | +) |
| 15 | + |
| 16 | +hatchet = Hatchet() |
| 17 | + |
| 18 | +REPLY_EVENT_KEY = "support:customer-reply" |
| 19 | +TIMEOUT_SECONDS = 5 |
| 20 | + |
| 21 | + |
| 22 | +# > Models |
| 23 | +class SupportTicketInput(BaseModel): |
| 24 | + ticket_id: str |
| 25 | + customer_email: str |
| 26 | + subject: str |
| 27 | + body: str |
| 28 | + |
| 29 | + |
| 30 | +class TriageOutput(BaseModel): |
| 31 | + category: str |
| 32 | + priority: str |
| 33 | + |
| 34 | + |
| 35 | +class ReplyOutput(BaseModel): |
| 36 | + message: str |
| 37 | + |
| 38 | + |
| 39 | +class EscalationOutput(BaseModel): |
| 40 | + reason: str |
| 41 | + assigned_to: str |
| 42 | + |
| 43 | + |
| 44 | + |
| 45 | + |
| 46 | +# > Triage task |
| 47 | +@hatchet.task(input_validator=SupportTicketInput) |
| 48 | +async def triage_ticket(input: SupportTicketInput, ctx: Context) -> TriageOutput: |
| 49 | + """Classify the ticket into a category and priority.""" |
| 50 | + subject = input.subject.lower() |
| 51 | + body = input.body.lower() |
| 52 | + text = subject + " " + body |
| 53 | + |
| 54 | + if any(word in text for word in ["bill", "charge", "payment", "invoice"]): |
| 55 | + category = "billing" |
| 56 | + elif any(word in text for word in ["login", "password", "auth", "access"]): |
| 57 | + category = "account" |
| 58 | + else: |
| 59 | + category = "technical" |
| 60 | + |
| 61 | + if any(word in text for word in ["urgent", "critical", "down", "outage"]): |
| 62 | + priority = "high" |
| 63 | + elif any(word in text for word in ["twice", "broken", "error"]): |
| 64 | + priority = "medium" |
| 65 | + else: |
| 66 | + priority = "low" |
| 67 | + |
| 68 | + return TriageOutput(category=category, priority=priority) |
| 69 | + |
| 70 | + |
| 71 | + |
| 72 | + |
| 73 | +# > Generate reply task |
| 74 | +@hatchet.task(input_validator=SupportTicketInput) |
| 75 | +async def generate_reply(input: SupportTicketInput, ctx: Context) -> ReplyOutput: |
| 76 | + """Generate an initial support reply using Claude.""" |
| 77 | + api_key = os.environ.get("ANTHROPIC_API_KEY") |
| 78 | + |
| 79 | + if not api_key: |
| 80 | + return ReplyOutput( |
| 81 | + message=f"Thank you for contacting support about: {input.subject}. " |
| 82 | + "We are looking into this and will get back to you shortly." |
| 83 | + ) |
| 84 | + |
| 85 | + import anthropic |
| 86 | + |
| 87 | + client = anthropic.AsyncAnthropic(api_key=api_key) |
| 88 | + |
| 89 | + response = await client.messages.create( |
| 90 | + model="claude-sonnet-4-20250514", |
| 91 | + max_tokens=300, |
| 92 | + messages=[ |
| 93 | + { |
| 94 | + "role": "user", |
| 95 | + "content": ( |
| 96 | + f"You are a friendly support agent. Write a brief, helpful initial " |
| 97 | + f"reply to this support ticket.\n\n" |
| 98 | + f"Subject: {input.subject}\n" |
| 99 | + f"Message: {input.body}\n\n" |
| 100 | + f"Keep the reply under 3 sentences." |
| 101 | + ), |
| 102 | + } |
| 103 | + ], |
| 104 | + ) |
| 105 | + |
| 106 | + text = response.content[0].text |
| 107 | + return ReplyOutput(message=text) |
| 108 | + |
| 109 | + |
| 110 | + |
| 111 | + |
| 112 | +# > Escalate task |
| 113 | +@hatchet.task(input_validator=SupportTicketInput) |
| 114 | +async def escalate_ticket(input: SupportTicketInput, ctx: Context) -> EscalationOutput: |
| 115 | + """Escalate an unresolved ticket to the human support team.""" |
| 116 | + return EscalationOutput( |
| 117 | + reason=f"No customer reply within {TIMEOUT_SECONDS}s timeout", |
| 118 | + assigned_to="support-team@example.com", |
| 119 | + ) |
| 120 | + |
| 121 | + |
| 122 | + |
| 123 | + |
| 124 | +# > Support agent workflow |
| 125 | +@hatchet.durable_task(input_validator=SupportTicketInput) |
| 126 | +async def support_agent( |
| 127 | + input: SupportTicketInput, ctx: DurableContext |
| 128 | +) -> dict[str, Any]: |
| 129 | + # Step 1: Triage the ticket |
| 130 | + triage = await triage_ticket.aio_run(input) |
| 131 | + |
| 132 | + # Step 2: Generate an initial reply |
| 133 | + reply = await generate_reply.aio_run(input) |
| 134 | + |
| 135 | + # Step 3: Wait for a customer reply or timeout |
| 136 | + now = await ctx.aio_now() |
| 137 | + consider_events_since = now - timedelta(minutes=5) |
| 138 | + |
| 139 | + wait_result = await ctx.aio_wait_for( |
| 140 | + "await-customer-reply", |
| 141 | + or_( |
| 142 | + SleepCondition(timedelta(seconds=TIMEOUT_SECONDS)), |
| 143 | + UserEventCondition( |
| 144 | + event_key=REPLY_EVENT_KEY, |
| 145 | + scope=input.ticket_id, |
| 146 | + consider_events_since=consider_events_since, |
| 147 | + ), |
| 148 | + ), |
| 149 | + ) |
| 150 | + |
| 151 | + # The or-group result is {"CREATE": {"<condition_key>": ...}}. |
| 152 | + # Check whether the reply event condition was the one that resolved. |
| 153 | + resolved_key = list(wait_result["CREATE"].keys())[0] |
| 154 | + customer_replied = resolved_key == REPLY_EVENT_KEY |
| 155 | + |
| 156 | + if not customer_replied: |
| 157 | + # Step 4a: Timeout -> escalate |
| 158 | + await escalate_ticket.aio_run(input) |
| 159 | + return { |
| 160 | + "ticket_id": input.ticket_id, |
| 161 | + "status": "escalated", |
| 162 | + "triage_category": triage.category, |
| 163 | + "triage_priority": triage.priority, |
| 164 | + "initial_reply": reply.message, |
| 165 | + } |
| 166 | + |
| 167 | + # Step 4b: Customer replied -> resolve |
| 168 | + return { |
| 169 | + "ticket_id": input.ticket_id, |
| 170 | + "status": "resolved", |
| 171 | + "triage_category": triage.category, |
| 172 | + "triage_priority": triage.priority, |
| 173 | + "initial_reply": reply.message, |
| 174 | + } |
| 175 | + |
| 176 | + |
| 177 | + |
| 178 | + |
| 179 | +# > Worker registration |
| 180 | +def main() -> None: |
| 181 | + worker = hatchet.worker( |
| 182 | + "support-agent-worker", |
| 183 | + workflows=[support_agent, triage_ticket, generate_reply, escalate_ticket], |
| 184 | + ) |
| 185 | + worker.start() |
| 186 | + |
| 187 | + |
| 188 | +if __name__ == "__main__": |
| 189 | + main() |
| 190 | + |
| 191 | + |
0 commit comments