AI-powered tickets categorization and a daily fulfillment reminder for open tickets (#153)

* Add ai-based ticket categorization and you can add tags in the bot homepage

* made the CategoryTag model and refactored the code to support it

* Implemented the fulfillment reminder to send all open tickets in the past 24h for amber to check them :)

* removed the ability to manage categories from the UI/fixed stuff

* Fix type error

* removed question tags

* Re-add question tags to DB schema

This is only because we don't have migrations rn so we can't actually delete stuff from the DB

---------

Co-authored-by: MMK21Hub <50421330+MMK21Hub@users.noreply.github.com>
This commit is contained in:
Obay M. Rashad 2026-02-11 01:30:22 +02:00 committed by GitHub
parent bb4673a398
commit 367dd5a2a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 297 additions and 177 deletions

View file

@ -11,6 +11,6 @@ SLACK_USER_GROUP="S..."
SLACK_MAINTAINER_ID="U..."
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nephthys"
APP_TITLE="helper heidi"
PROGRAM="summer_of_making"
PROGRAM="flavortown"
HACK_CLUB_AI_API_KEY="sk-hc-v1-..."
BASE_URL="https://..."

View file

@ -1,14 +1,15 @@
# Nephthys
Nephthys is the bot powering #summer-of-making-help and #identity-help in the Hack Club Slack! Below is a guide to set her up for developing and here's a list of some of her features :)
Nephthys is the bot powering many support channels in the Hack Club Slack such as #flavortown-help and #identity-help! Below is a guide to set her up for developing and here's a list of some of her features :)
## Features
### Question tags
Quite often in help channels, the same question gets asked again and again. Helpers can associate tickets with a "question tag", which are pre-defined questions/issue such as "What's the payout range?", "How to top up grant?", or "404 on /shop". We can then keep track of how these questions trend over time, to provide a feedback loop for people building the event/platform/YSWS.
### Category tags
They can be added to tickets in the private tickets channel.
Category tags are used to classify tickets into broader categories such as "Fulfillment", "Identity", or "Platform Issues". When a new ticket is created, AI analyzes the message content and automatically assigns the most relevant category tag.
Helpers can reassign these tags in the private tickets channel if the AI suggestion is incorrect.
### Team tags

View file

@ -11,6 +11,7 @@ from starlette.applications import Starlette
from nephthys.tasks.close_stale import close_stale_tickets
from nephthys.tasks.daily_stats import send_daily_stats
from nephthys.tasks.fulfillment_reminder import send_fulfillment_reminder
from nephthys.tasks.update_helpers import update_helpers
from nephthys.utils.delete_thread import process_queue
from nephthys.utils.env import env
@ -46,6 +47,15 @@ async def main(_app: Starlette):
scheduler = AsyncIOScheduler(timezone="Europe/London")
if env.daily_summary:
scheduler.add_job(send_daily_stats, "cron", hour=0, minute=0)
scheduler.add_job(
send_fulfillment_reminder,
"cron",
hour=14,
minute=0,
timezone="Europe/London",
)
scheduler.add_job(
close_stale_tickets,
"interval",

View file

@ -10,7 +10,7 @@ from nephthys.events.message.send_backend_message import backend_message_fallbac
from nephthys.utils.env import env
async def assign_question_tag_callback(
async def assign_category_tag_callback(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await ack()
@ -30,7 +30,7 @@ async def assign_question_tag_callback(
user = await env.db.user.find_unique(where={"slackId": user_id})
if not user or not user.helper:
logging.warning(
f"Unauthorized user attempted to assign question tag user_id={user_id}"
f"Unauthorized user attempted to assign category tag user_id={user_id}"
)
await client.chat_postEphemeral(
channel=channel_id,
@ -42,7 +42,7 @@ async def assign_question_tag_callback(
ticket = await env.db.ticket.update(
where={"ticketTs": ts},
data={
"questionTag": (
"categoryTag": (
{"connect": {"id": tag_id}}
if tag_id is not None
else {"disconnect": True}
@ -52,7 +52,7 @@ async def assign_question_tag_callback(
)
if not ticket:
logging.error(
f"Failed to find corresponding ticket to update question tag ticket_ts={ts}"
f"Failed to find corresponding ticket to update category tag ticket_ts={ts}"
)
return
@ -78,11 +78,11 @@ async def assign_question_tag_callback(
author_user_id=ticket.openedBy.slackId,
msg_ts=ticket.msgTs,
past_tickets=other_tickets,
current_question_tag_id=tag_id,
current_category_tag_id=tag_id,
reopened_by=ticket.reopenedBy,
),
)
logging.info(
f"Updated question tag on ticket ticket_id={ticket.id} tag_id={tag_id}"
f"Updated category tag on ticket ticket_id={ticket.id} tag_id={tag_id}"
)

View file

@ -1,49 +0,0 @@
from slack_bolt.async_app import AsyncAck
from slack_sdk.web.async_client import AsyncWebClient
from nephthys.utils.env import env
from nephthys.utils.logging import send_heartbeat
from nephthys.views.modals.create_question_tag import get_create_question_tag_modal
async def create_question_tag_view_callback(
ack: AsyncAck, body: dict, client: AsyncWebClient
):
"""
Callback for the create question tag view submission
"""
await ack()
user_id = body["user"]["id"]
user = await env.db.user.find_unique(where={"slackId": user_id})
if not user or not user.helper:
await send_heartbeat(
f"Attempted to create question tag by non-helper <@{user_id}>"
)
return
label = body["view"]["state"]["values"]["tag_label"]["tag_label"]["value"]
await env.db.questiontag.create(data={"label": label})
# await open_app_home("question-tags", client, user_id)
async def create_question_tag_btn_callback(
ack: AsyncAck, body: dict, client: AsyncWebClient
):
"""
Open modal to create a question tag
"""
await ack()
user_id = body["user"]["id"]
trigger_id = body["trigger_id"]
user = await env.db.user.find_unique(where={"slackId": user_id})
if not user or not user.helper:
await send_heartbeat(
f"Attempted to open create-question-tag modal by non-helper <@{user_id}>"
)
return
view = get_create_question_tag_modal()
await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id)

View file

@ -8,62 +8,46 @@ async def backend_message_blocks(
author_user_id: str,
msg_ts: str,
past_tickets: int,
current_question_tag_id: int | None = None,
current_category_tag_id: int | None = None,
reopened_by: User | None = None,
) -> list[dict]:
thread_url = f"https://hackclub.slack.com/archives/{env.slack_help_channel}/p{msg_ts.replace('.', '')}"
if current_question_tag_id is not None:
if current_category_tag_id is not None:
options = [
{
"text": {
"type": "plain_text",
"text": tag.label,
"text": tag.name,
},
"value": f"{tag.id}",
}
for tag in await env.db.questiontag.find_many()
for tag in await env.db.categorytag.find_many()
]
initial_option = [
option
for option in options
if option["value"] == f"{current_question_tag_id}"
if option["value"] == f"{current_category_tag_id}"
][0]
else:
initial_option = None
question_tags_dropdown = {
category_tags_dropdown = {
"type": "input",
"label": {"type": "plain_text", "text": "Question tag", "emoji": True},
"label": {"type": "plain_text", "text": "Category tag", "emoji": True},
"element": {
"type": "external_select",
"action_id": "question-tag-list",
"action_id": "category-tag-list",
"placeholder": {
"type": "plain_text",
"text": "Which question tag fits?",
"text": "Which category tag fits?",
},
"min_query_length": 0,
},
}
if initial_option:
question_tags_dropdown["element"]["initial_option"] = initial_option
category_tags_dropdown["element"]["initial_option"] = initial_option
return [
question_tags_dropdown,
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "If none of the existing tags fit :point_right:",
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": ":wrench: new question tag",
"emoji": True,
},
"action_id": "create-question-tag",
},
},
category_tags_dropdown,
{
"type": "input",
"label": {"type": "plain_text", "text": "Team tags", "emoji": True},
@ -108,7 +92,7 @@ async def send_backend_message(
description: str,
past_tickets: int,
client: AsyncWebClient,
current_question_tag_id: int | None = None,
current_category_tag_id: int | None = None,
reopened_by: User | None = None,
display_name: str | None = None,
profile_pic: str | None = None,
@ -119,7 +103,7 @@ async def send_backend_message(
channel=env.slack_ticket_channel,
text=backend_message_fallback_text(author_user_id, description, reopened_by),
blocks=await backend_message_blocks(
author_user_id, msg_ts, past_tickets, current_question_tag_id, reopened_by
author_user_id, msg_ts, past_tickets, current_category_tag_id, reopened_by
),
username=display_name,
icon_url=profile_pic,

View file

@ -1,4 +1,5 @@
import logging
import string
from datetime import datetime
from typing import Any
from typing import Dict
@ -8,6 +9,8 @@ from prometheus_client import Histogram
from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient
from nephthys.events.message.send_backend_message import backend_message_blocks
from nephthys.events.message.send_backend_message import backend_message_fallback_text
from nephthys.events.message.send_backend_message import send_backend_message
from nephthys.macros import run_macro
from nephthys.utils.env import env
@ -18,6 +21,7 @@ from nephthys.utils.ticket_methods import delete_and_clean_up_ticket
from prisma.enums import TicketStatus
from prisma.enums import UserType
from prisma.models import User
from prisma.types import TicketCreateInput
# Message subtypes that should be handled by on_message (messages with no subtype are always handled)
ALLOWED_SUBTYPES = ["file_share", "me_message", "thread_broadcast"]
@ -28,6 +32,12 @@ TICKET_TITLE_GENERATION_DURATION = Histogram(
)
TICKET_CATEGORY_GENERATION_DURATION = Histogram(
"nephthys_ticket_category_generation_duration_seconds",
"How long it takes to generate a category tag using AI",
)
async def handle_message_sent_to_channel(event: Dict[str, Any], client: AsyncWebClient):
"""Tell a non-helper off because they sent a thread message with the 'send to channel' box checked."""
await client.chat_delete(
@ -173,27 +183,55 @@ async def handle_new_question(
):
title = await generate_ticket_title(text)
async with perf_timer(
"AI category tag generation", TICKET_CATEGORY_GENERATION_DURATION
):
category_tag_id = await generate_category_tag(text)
if category_tag_id:
blocks = await backend_message_blocks(
author_user_id=author_id,
msg_ts=event["ts"],
past_tickets=past_tickets,
current_category_tag_id=category_tag_id,
)
await client.chat_update(
channel=env.slack_ticket_channel,
ts=ticket_message_ts,
text=backend_message_fallback_text(author_id, text),
blocks=blocks,
)
user_facing_message_ts = user_facing_message["ts"]
if not user_facing_message_ts:
logging.error(f"User-facing message has no ts: {user_facing_message}")
return
async with perf_timer("Creating ticket in DB"):
ticket = await env.db.ticket.create(
{
"title": title,
"description": text,
"msgTs": event["ts"],
"ticketTs": ticket_message_ts,
"openedBy": {"connect": {"id": db_user.id}},
"userFacingMsgs": {
"create": {
"channelId": event["channel"],
"ts": user_facing_message_ts,
}
},
ticket_data: TicketCreateInput = {
"title": title,
"description": text,
"msgTs": event["ts"],
"ticketTs": ticket_message_ts,
"openedBy": {"connect": {"id": db_user.id}},
"userFacingMsgs": {
"create": {
"channelId": event["channel"],
"ts": user_facing_message_ts,
}
},
)
}
if category_tag_id:
ticket_data["categoryTag"] = {"connect": {"id": category_tag_id}}
ticket = await env.db.ticket.create(ticket_data)
if not category_tag_id:
logging.warning(
f"Failed to generate category tag for ticket_id={ticket.id}"
)
try:
await client.reactions_add(
@ -341,3 +379,56 @@ async def generate_ticket_title(text: str):
# Capitalise first letter
title = title[0].upper() + title[1:] if len(title) > 1 else title.upper()
return title
async def generate_category_tag(text: str) -> int | None:
category_tags = await env.db.categorytag.find_many()
if not category_tags:
return None
tag_options = ", ".join([tag.name for tag in category_tags])
tag_map = {tag.name.lower(): tag for tag in category_tags}
if not env.ai_client:
return None
model = "google/gemini-3-flash-preview"
try:
response = await env.ai_client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": (
"You are a helpful assistant that categorizes support tickets! "
f"Choose the best tag from this list: [{tag_options}]. "
"Return ONLY the exact tag name. If none fit, return 'None'."
),
},
{
"role": "user",
"content": f"Ticket content: {text}",
},
],
)
except OpenAIError as e:
await send_heartbeat(f"Failed to get AI response for tag generation: {e}")
return None
if not (len(response.choices) and response.choices[0].message.content):
return None
suggested_tag_label = response.choices[0].message.content.strip()
suggested_clean = suggested_tag_label.strip(string.punctuation)
original_label = tag_map.get(suggested_clean.lower())
if not original_label:
original_label = tag_map.get(suggested_tag_label.lower())
if original_label:
return original_label.id
return None

View file

@ -10,6 +10,7 @@ from nephthys.macros.shipcertqueue import ShipCertQueue
from nephthys.macros.shipwrights import Shipwrights
from nephthys.macros.thread import Thread
from nephthys.macros.trigger_daily_stats import DailyStats
from nephthys.macros.trigger_fulfillment_reminder import FulfillmentReminder
from nephthys.macros.types import Macro
from nephthys.utils.env import env
from nephthys.utils.logging import send_heartbeat
@ -28,6 +29,7 @@ macro_list: list[type[Macro]] = [
Thread,
Reopen,
DailyStats,
FulfillmentReminder,
Shipwrights,
]

View file

@ -61,7 +61,7 @@ class Reopen(Macro):
msg_ts=ticket.msgTs,
past_tickets=other_tickets,
client=env.slack_client,
current_question_tag_id=ticket.questionTagId,
current_category_tag_id=ticket.categoryTagId,
reopened_by=helper,
display_name=author.display_name(),
profile_pic=author.profile_pic_512x(),

View file

@ -0,0 +1,20 @@
from nephthys.macros.types import Macro
from nephthys.tasks import fulfillment_reminder
from nephthys.utils.env import env
class FulfillmentReminder(Macro):
name = "fulfillment_reminder"
can_run_on_closed = True
async def run(self, ticket, helper, **kwargs):
"""Development-only macro to manually trigger the fulfillment reminder message"""
if not env.environment == "development":
await env.slack_client.chat_postEphemeral(
channel=env.slack_help_channel,
thread_ts=ticket.msgTs,
user=helper.slackId,
text="The `fulfillment_reminder` macro can only be run in development environments.",
)
return
await fulfillment_reminder.send_fulfillment_reminder()

View file

@ -6,14 +6,14 @@ from thefuzz import process
from nephthys.utils.env import env
async def get_question_tags(payload: dict) -> list[dict[str, dict[str, str] | str]]:
tags = await env.db.questiontag.find_many()
async def get_category_tags(payload: dict) -> list[dict[str, dict[str, str] | str]]:
tags = await env.db.categorytag.find_many()
if not tags:
return []
keyword = payload.get("value")
if keyword:
tag_names = [tag.label for tag in tags]
tag_names = [tag.name for tag in tags]
scores = process.extract(keyword, tag_names, scorer=fuzz.ratio, limit=100)
matching_tags = [tags[tag_names.index(score[0])] for score in scores]
else:
@ -21,7 +21,7 @@ async def get_question_tags(payload: dict) -> list[dict[str, dict[str, str] | st
res = [
{
"text": {"type": "plain_text", "text": f"{tag.label}"},
"text": {"type": "plain_text", "text": f"{tag.name}"},
"value": str(tag.id),
}
for tag in matching_tags

View file

@ -0,0 +1,105 @@
import logging
from datetime import datetime
from datetime import timedelta
from nephthys.utils.env import env
from nephthys.utils.logging import send_heartbeat
from nephthys.utils.ticket_methods import get_question_message_link
from prisma.enums import TicketStatus
def slack_timestamp(dt: datetime, format: str = "date_short") -> str:
fallback = dt.isoformat().replace("T", " ")
return f"<!date^{int(dt.timestamp())}^{{{format}}}|{fallback}>"
async def send_fulfillment_reminder():
"""
Checks for 'Shop/fulfillment query' tag and sends a reminder with open tickets for the fulfillment team/amber
Run daily at 2 PM (London Time)
"""
target_tag_name = "Shop/fulfillment query"
target_slack_id = "U054VC2KM9P"
logging.info("Running fulfillment team reminder task")
try:
tag = await env.db.categorytag.find_unique(where={"name": target_tag_name})
if not tag:
logging.info(
f"Tag '{target_tag_name}' not found. Skipping fulfillment reminder."
)
return
now = datetime.now()
twenty_four_hours_ago = now - timedelta(hours=24)
tickets = await env.db.ticket.find_many(
where={
"categoryTagId": tag.id,
"status": {"in": [TicketStatus.OPEN, TicketStatus.IN_PROGRESS]},
"createdAt": {"gte": twenty_four_hours_ago},
},
include={"openedBy": True, "tagsOnTickets": {"include": {"tag": True}}},
)
msg_header = f"oh hi <@{target_slack_id}>! i found some fulfillment tickets for you! :rac_cute:"
if not tickets:
logging.info("No open fulfillment tickets found. Skipping Slack reminder.")
return
else:
msg_lines = [
f":rac_shy: *tickets needing attention ({len(tickets)})*",
"here are the open tickets from the last 24 hours:",
]
for i, ticket in enumerate(tickets):
label = (
ticket.title or ticket.description[:100] or f"Ticket #{ticket.id}"
)
link = get_question_message_link(ticket)
created_ts = slack_timestamp(ticket.createdAt, format="date_short")
tags = ticket.tagsOnTickets
tags_string = (
" (" + ", ".join(f"*{t.tag.name}*" for t in tags if t.tag) + ")"
if tags
else ""
)
msg_lines.append(
f"{i + 1}. <{link}|{label}>{tags_string} (created {created_ts})"
)
msg_body = "\n".join(msg_lines)
full_msg = f"""
{msg_header}
{msg_body}
"""
await env.slack_client.chat_postMessage(
channel=env.slack_bts_channel,
text=f"Reminder for <@{target_slack_id}>",
blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": full_msg}}],
)
logging.info("Fulfillment reminder sent successfully.")
except Exception as e:
logging.error(f"Failed to send fulfillment reminder: {e}", exc_info=True)
try:
await send_heartbeat(
"Failed to send fulfillment reminder",
messages=[str(e)],
)
except Exception as slack_e:
logging.error(
f"Could not send error notification to Slack maintainer: {slack_e}"
)

View file

@ -6,10 +6,8 @@ from slack_bolt.async_app import AsyncApp
from slack_bolt.context.ack.async_ack import AsyncAck
from slack_sdk.web.async_client import AsyncWebClient
from nephthys.actions.assign_question_tag import assign_question_tag_callback
from nephthys.actions.assign_category_tag import assign_category_tag_callback
from nephthys.actions.assign_team_tag import assign_team_tag_callback
from nephthys.actions.create_question_tag import create_question_tag_btn_callback
from nephthys.actions.create_question_tag import create_question_tag_view_callback
from nephthys.actions.create_team_tag import create_team_tag_btn_callback
from nephthys.actions.create_team_tag import create_team_tag_view_callback
from nephthys.actions.resolve import resolve
@ -21,7 +19,7 @@ from nephthys.events.channel_join import channel_join
from nephthys.events.channel_left import channel_left
from nephthys.events.message_creation import on_message
from nephthys.events.message_deletion import on_message_deletion
from nephthys.options.question_tags import get_question_tags
from nephthys.options.category_tags import get_category_tags
from nephthys.options.team_tags import get_team_tags
from nephthys.utils.env import env
from nephthys.utils.performance import perf_timer
@ -62,9 +60,9 @@ async def handle_team_tag_list_options(ack: AsyncAck, payload: dict):
await ack(options=tags)
@app.options("question-tag-list")
async def handle_question_tag_list_options(ack: AsyncAck, payload: dict):
tags = await get_question_tags(payload)
@app.options("category-tag-list")
async def handle_category_tag_list_options(ack: AsyncAck, payload: dict):
tags = await get_category_tags(payload)
await ack(options=tags)
@ -100,13 +98,6 @@ async def create_team_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebC
await create_team_tag_btn_callback(ack, body, client)
@app.action("create-question-tag")
async def create_question_tag(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await create_question_tag_btn_callback(ack, body, client)
@app.view("create_team_tag")
async def create_team_tag_view(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
@ -114,13 +105,6 @@ async def create_team_tag_view(
await create_team_tag_view_callback(ack, body, client)
@app.view("create_question_tag")
async def create_question_tag_view(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await create_question_tag_view_callback(ack, body, client)
@app.action("tag-subscribe")
async def tag_subscribe(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient):
await tag_subscribe_callback(ack, body, client)
@ -132,11 +116,11 @@ async def assign_team_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebC
await assign_team_tag_callback(ack, body, client)
@app.action("question-tag-list")
async def assign_question_tag(
@app.action("category-tag-list")
async def assign_category_tag(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await assign_question_tag_callback(ack, body, client)
await assign_category_tag_callback(ack, body, client)
@app.command("/dm-magic-link")

View file

@ -2,7 +2,7 @@ from nephthys.utils.env import env
from prisma.models import User
def header_buttons(current_view: str):
def header_buttons(current_view: str, user: User | None):
buttons = []
buttons.append(
@ -59,6 +59,6 @@ def title_line():
def get_header(user: User | None, current: str = "dashboard") -> list[dict]:
return [
title_line(),
header_buttons(current),
header_buttons(current, user),
{"type": "divider"},
]

View file

@ -1,43 +0,0 @@
def get_create_question_tag_modal():
return {
"type": "modal",
"callback_id": "create_question_tag",
"title": {
"type": "plain_text",
"text": "Create question tag",
"emoji": True,
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": """
Question tags are used to keep track of how often specific questions are being asked, so tag names should be brief but specific enough to uniquely identify the question.
Examples:
Missing CSS on site
What is Flavortown?
"You're not eligible" when trying to ship project
""",
},
},
{
"type": "input",
"block_id": "tag_label",
"label": {
"type": "plain_text",
"text": "Question label",
},
"element": {
"type": "plain_text_input",
"action_id": "tag_label",
},
},
],
"submit": {
"type": "plain_text",
"text": ":rac_question: Create!",
"emoji": True,
},
}

View file

@ -33,6 +33,7 @@ model User {
reopenedTickets Ticket[] @relation("ReopenedTickets")
tagSubscriptions UserTagSubscription[]
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
helper Boolean @default(false)
createdAt DateTime @default(now())
@ -74,6 +75,9 @@ model Ticket {
questionTag QuestionTag? @relation("QuestionTagTickets", fields: [questionTagId], references: [id])
questionTagId Int?
categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
categoryTagId Int?
createdAt DateTime @default(now())
}
@ -128,3 +132,14 @@ model BotMessage {
@@unique([ts, channelId])
}
model CategoryTag {
id Int @id @unique @default(autoincrement())
name String @unique
tickets Ticket[] @relation("CategoryTagTickets")
createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
createdById Int?
createdAt DateTime @default(now())
}