diff --git a/README.md b/README.md index 703ffdb..19c1329 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ projects for: | ⭐ [Pull Request Review Reminder (Purrr)](./purrr/) | Streamline code reviews and cut down turnaround time to merge pull requests | GitHub ↔ Slack | | ⭐ [ReviewKitteh](./reviewkitteh/) | Monitor pull requests, and meow at random people | GitHub, Google Sheets, Slack | | 🐍 [Task chain](./task_chain/) | Run a sequence of tasks with fault tolerance | Slack | +| 🐍 [Slack Support](./slack_support/) | Categorize slack support requests using AI and make sure appropiate people handle them | Slack, Google Sheets, Gemini | > [!NOTE] > 🐍 = Python implementation, ⭐ = Starlark implementation. diff --git a/slack_support/README.md b/slack_support/README.md new file mode 100644 index 0000000..6bcd6ea --- /dev/null +++ b/slack_support/README.md @@ -0,0 +1,64 @@ +# AI Driven Slack Support + +This automation uses a slack bot to receive requests for help as a bot mention. Once a request for help is received, the subject of the request is inferred using Google's Gemini AI. The appropiate person, per a predetemined table of expertize that reside in a Google Doc, is mention. The person can then `!take` the request, and later `!resolve` it. If no one picks up the request for a configurable duration, the automation will remind the person that a request is pending. + +For example, given this expertise table: + +``` + | A | B | C +---+---------+-----------+-------------- + 1 | Itay | U12345678 | cats,dogs + 2 | Haim | U87654321 | russian +``` + +This would happen: + +![demo](./demo.png) + +# Deploy + +Requirements: + +- Slack integration is set up. See https://docs.autokitteh.com/tutorials/new_connections/slack. +- Google integration is set up. See TODO. + +First apply the manifest: + +``` +$ ak manifest apply autokitteh.yaml +``` + +Then initialize the google and slack connections. This will authenticate them to the desired Slack workspace and Google account. + +``` +$ ak connection init slack_support/myslack +$ ak connection init slack_support/mygsheets +``` + +Now acquire a Gemini API key from google. Go to https://ai.google.dev/gemini-api/docs/api-key and follow the instructions. +Set the variable in autokitteh: + +``` +$ ak env set --env slack_support/default --secret GEMINI_API_KEY +``` + +Now create your Google Sheet containing the schedule, it shoud look like this: + +``` + | A | B | C +---+---------+-----------+-------------- + 1 | Gizmo | U12345678 | topic1,topic2 + 2 | George | U87654321 | topic3 +``` + +Set the sheet ID in the autokitteh environment: + +``` +$ ak env set --env slack_support/default DIRECTORY_GOOGLE_SHEET_ID +``` + +Now you are ready to roll. Deploy your project: + +``` +$ ak deploy --project slack_support --dir . +``` diff --git a/slack_support/autokitteh.yaml b/slack_support/autokitteh.yaml new file mode 100644 index 0000000..44aa09a --- /dev/null +++ b/slack_support/autokitteh.yaml @@ -0,0 +1,29 @@ +version: v1 + +project: + name: slack_support + vars: + - # Google Sheet ID for a sheet that contains a mapping between users and the + # the topics they can suport. + # Expected google sheet structure: + # | A | B | C + # --+---------+-----------+-------------- + # 1 | Gizmo | U12345678 | topic1,topic2 + # 2 | George | U87654321 | topic3 + name: DIRECTORY_GOOGLE_SHEET_ID + - # A gemini key must be acquired from https://aistudio.google.com/app/apikey. + name: GEMINI_API_KEY + - # Time in minutes to wait for the issue to be picked up before + # reminder. + name: HELP_REQUEST_TIMEOUT_MINUTES + value: "10" + connections: + - name: myslack + integration: slack + - name: mygsheets + integration: googlesheets + triggers: + - name: slack_app_mention + connection: myslack + event_type: app_mention + call: main.py:on_slack_mention diff --git a/slack_support/demo.png b/slack_support/demo.png new file mode 100644 index 0000000..f259f21 Binary files /dev/null and b/slack_support/demo.png differ diff --git a/slack_support/directory.py b/slack_support/directory.py new file mode 100644 index 0000000..81998b3 --- /dev/null +++ b/slack_support/directory.py @@ -0,0 +1,26 @@ +import os +from collections import namedtuple +from autokitteh.google import google_sheets_client + +DIRECTORY_GOOGLE_SHEET_ID = os.getenv("DIRECTORY_GOOGLE_SHEET_ID") + +gsheets = google_sheets_client("mygsheets") + + +Person = namedtuple("Person", ["name", "slack_id", "topics"]) + + +def load() -> dict[str, list[Person]]: # topic -> list of people + vs = ( + gsheets.spreadsheets() + .values() + .get(spreadsheetId=DIRECTORY_GOOGLE_SHEET_ID, range="A1:C100") + .execute() + .get("values", []) + ) + + ppl = [Person(v[0], v[1], v[2].split(",")) for v in vs] + + topics = set([topic for person in ppl for topic in person.topics]) + + return {topic: [person for person in ppl if topic in person.topics] for topic in topics} diff --git a/slack_support/gemini.py b/slack_support/gemini.py new file mode 100644 index 0000000..6707f61 --- /dev/null +++ b/slack_support/gemini.py @@ -0,0 +1,29 @@ +import os +import json + +import google.generativeai as genai + +# How do i know if this is "picklable" or not? It has no return value? Do I need to run this every time? +# Is this running in an activity? +genai.configure(api_key=os.getenv("GEMINI_API_KEY")) + +# Make this run every time outside of an activity if it's in global scope? +model = genai.GenerativeModel( + "gemini-1.5-flash", + generation_config={"response_mime_type": "application/json"}, +) + + +def extract_topic(text: str, topics: set[str]) -> str: + prompt = f"""Topics: {', '.join(topics)} +Is the following text a request for help with one of these topics? +Example responses: +If a request for help and a topic from the list: {{"help": true, "topic": "cats" }} +If a request for help and topic is not from the list: {{"help": true, "topic": None }} +If not a request for help: {{"help": false}} + +Text to analyze: +{text}""" + + resp = json.loads(model.generate_content(prompt).text) + return resp.get("help"), resp.get("topic") diff --git a/slack_support/main.py b/slack_support/main.py new file mode 100644 index 0000000..68d59b8 --- /dev/null +++ b/slack_support/main.py @@ -0,0 +1,74 @@ +import os +from datetime import datetime + +import autokitteh +from autokitteh.slack import slack_client + +import gemini +import directory + +HELP_REQUEST_TIMEOUT_MINUTES = int(os.getenv("HELP_REQUEST_TIMEOUT_MINUTES")) + +slack_client = slack_client("myslack") + + +def on_slack_mention(event): + def send(text): + """Helper function to just post some text back in the same thread.""" + slack_client.chat_postMessage( + channel=event.data.channel, + thread_ts=event.data.ts, + text=text, + ) + + # prints are used for logging, and can be seen in the console output. + print(f"sent: '{text}'") + + topics_to_people = directory.load() + + help, topic = gemini.extract_topic(event.data.text, topics_to_people.keys()) + if not help: + return + + people = topics_to_people.get(topic) + if not people: + send(f"Sorry, I don't know who to ask about {topic}.") + return + + mentions = ", ".join(f"<@{p.slack_id}>" for p in people) + + send(f"""People who can help are: {mentions}. +Responders: please reply in this thread with `!take` or `!resolve`. +If not taken or resolved, I will remind you in {HELP_REQUEST_TIMEOUT_MINUTES}m. +""") + + # From this point on we are interested in any message that is added to the thread. + # Further below we'll consume the messages and act on them using `next_event`. + s = autokitteh.subscribe( + "myslack", + f'data.type == "message" && data.thread_ts == "{event.data.ts}" && data.text.startsWith("!")', + ) + + taken_by = None + start_time = datetime.now() + + while True: + msg = autokitteh.next_event(s, timeout=60) + + if not msg: # timeout + dt = datetime.now().total_seconds() - start_time.total_seconds() + print(f"timeout, dt={dt}") + + if not taken_by and dt >= HELP_REQUEST_TIMEOUT_MINUTES * 60: + send(f"Reminder: {mentions}, please respond.") + start_time = datetime.now() + continue + + cmd = msg.text.strip()[1:] + if cmd == "resolve": + send("Issue is now resolved.") + # this effectively ends the workflow. + return + if cmd == "take": + taken_by = msg.user + send(f"Thanks <@{msg.user}>, you've taken this issue.")