Spaces:
Sleeping
Sleeping
github-actions[bot]
commited on
Commit
·
9ca48e9
1
Parent(s):
21107d3
sync: automatic content update from github
Browse files- README.md +10 -4
- app.py +253 -0
- .gitattributes → gitattributes +0 -0
- index.html +0 -19
- requirements.txt +5 -0
- style.css +0 -28
README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
---
|
| 2 |
title: Partner Management
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
title: Partner Management
|
| 3 |
+
emoji: 🦀
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: pink
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.45.0
|
| 8 |
+
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
This dashboard surfaces support tickets for partner management teams. Use the
|
| 13 |
+
"Zendesk Lookup" and "Jira Lookup" tabs to search by site, keyword, and date
|
| 14 |
+
range with filters scoped to each tab.
|
| 15 |
+
|
| 16 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
app.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import html
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from atlassian import Jira
|
| 6 |
+
import requests
|
| 7 |
+
from openai import OpenAI
|
| 8 |
+
from datetime import date, timedelta
|
| 9 |
+
|
| 10 |
+
# -------------------------
|
| 11 |
+
# Environment-based secrets
|
| 12 |
+
# -------------------------
|
| 13 |
+
JIRA_URL = os.getenv("JIRA_URL")
|
| 14 |
+
JIRA_USERNAME = os.getenv("JIRA_USERNAME")
|
| 15 |
+
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN")
|
| 16 |
+
|
| 17 |
+
ZENDESK_EMAIL = os.getenv("ZENDESK_EMAIL")
|
| 18 |
+
ZENDESK_SUBDOMAIN = os.getenv("ZENDESK_SUBDOMAIN")
|
| 19 |
+
ZENDESK_API_KEY = os.getenv("ZENDESK_API_KEY")
|
| 20 |
+
|
| 21 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 22 |
+
client = OpenAI(api_key=OPENAI_API_KEY)
|
| 23 |
+
|
| 24 |
+
# -------------------------
|
| 25 |
+
# JIRA Client
|
| 26 |
+
# -------------------------
|
| 27 |
+
jira = Jira(url=JIRA_URL, username=JIRA_USERNAME, password=JIRA_API_TOKEN)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# -------------------------
|
| 31 |
+
# OpenAI Summarization
|
| 32 |
+
# -------------------------
|
| 33 |
+
@st.cache_data(show_spinner=False)
|
| 34 |
+
def summarize_ticket(text: str) -> str:
|
| 35 |
+
if not text:
|
| 36 |
+
return "No description"
|
| 37 |
+
prompt = (
|
| 38 |
+
"Summarize this Zendesk ticket in 1–3 sentences:\n\n" + text + "\n\nSummary:"
|
| 39 |
+
)
|
| 40 |
+
resp = client.chat.completions.create(
|
| 41 |
+
model="gpt-4o-mini",
|
| 42 |
+
messages=[{"role": "user", "content": prompt}],
|
| 43 |
+
temperature=0.3,
|
| 44 |
+
max_tokens=150,
|
| 45 |
+
)
|
| 46 |
+
return resp.choices[0].message.content.strip()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# -------------------------
|
| 50 |
+
# Zendesk Search Function
|
| 51 |
+
# -------------------------
|
| 52 |
+
def search_zendesk_tickets(site_name: str, keyword: str) -> pd.DataFrame:
|
| 53 |
+
terms = []
|
| 54 |
+
if site_name:
|
| 55 |
+
terms.append(f'"{site_name}"')
|
| 56 |
+
if keyword:
|
| 57 |
+
terms.append(f'"{keyword}"')
|
| 58 |
+
query_str = " ".join(terms)
|
| 59 |
+
|
| 60 |
+
url = f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/search.json"
|
| 61 |
+
params = {"query": f"type:ticket {query_str}", "include": "users"}
|
| 62 |
+
auth = (f"{ZENDESK_EMAIL}/token", ZENDESK_API_KEY)
|
| 63 |
+
resp = requests.get(url, auth=auth, params=params)
|
| 64 |
+
if not resp.ok:
|
| 65 |
+
st.error(f"Zendesk error {resp.status_code}")
|
| 66 |
+
return pd.DataFrame()
|
| 67 |
+
|
| 68 |
+
tickets = resp.json().get("results", [])
|
| 69 |
+
rows = []
|
| 70 |
+
for t in tickets:
|
| 71 |
+
rows.append(
|
| 72 |
+
{
|
| 73 |
+
"ID": t["id"],
|
| 74 |
+
"Subject": html.escape(t.get("subject", "")),
|
| 75 |
+
"Status": html.escape(t.get("status", "")),
|
| 76 |
+
"Created At": t.get("created_at", ""),
|
| 77 |
+
"Updated At": t.get("updated_at", ""),
|
| 78 |
+
"Description": t.get("description", ""), # keep for summary
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
df = pd.DataFrame(rows)
|
| 82 |
+
|
| 83 |
+
# generate summaries and attach as new column
|
| 84 |
+
df["OpenAI Ticket Summary"] = df["Description"].apply(summarize_ticket)
|
| 85 |
+
return df
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# -------------------------
|
| 89 |
+
# Jira Search Function
|
| 90 |
+
# -------------------------
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@st.cache_data(show_spinner=False)
|
| 94 |
+
def search_jira_issues(
|
| 95 |
+
site_name: str, keyword: str, start_date: date, end_date: date
|
| 96 |
+
) -> pd.DataFrame:
|
| 97 |
+
# Build JQL clauses
|
| 98 |
+
clauses = []
|
| 99 |
+
if site_name:
|
| 100 |
+
clauses.append(f'text ~ "{site_name}"')
|
| 101 |
+
if keyword:
|
| 102 |
+
clauses.append(f'text ~ "{keyword}"')
|
| 103 |
+
clauses.append(f'created >= "{start_date.isoformat()}"')
|
| 104 |
+
clauses.append(f'created <= "{end_date.isoformat()}"')
|
| 105 |
+
jql = " AND ".join(clauses)
|
| 106 |
+
|
| 107 |
+
# Execute the JQL query, limiting to 100 issues
|
| 108 |
+
resp = jira.jql(jql, limit=100)
|
| 109 |
+
issues = resp.get("issues", [])
|
| 110 |
+
|
| 111 |
+
rows = []
|
| 112 |
+
for issue in issues:
|
| 113 |
+
f = issue["fields"]
|
| 114 |
+
rows.append(
|
| 115 |
+
{
|
| 116 |
+
"Key": issue["key"],
|
| 117 |
+
"Summary": html.escape(f.get("summary", "")),
|
| 118 |
+
"Status": html.escape(f.get("status", {}).get("name", "")),
|
| 119 |
+
"Created At": f.get("created", ""),
|
| 120 |
+
"Updated At": f.get("updated", ""),
|
| 121 |
+
}
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
return pd.DataFrame(rows)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# -------------------------
|
| 128 |
+
# App Config
|
| 129 |
+
# -------------------------
|
| 130 |
+
st.set_page_config(layout="wide")
|
| 131 |
+
st.title("Unified Support Dashboard")
|
| 132 |
+
|
| 133 |
+
if "zendesk_df" not in st.session_state:
|
| 134 |
+
st.session_state.zendesk_df = pd.DataFrame()
|
| 135 |
+
if "jira_df" not in st.session_state:
|
| 136 |
+
st.session_state.jira_df = pd.DataFrame()
|
| 137 |
+
|
| 138 |
+
# -------------------------
|
| 139 |
+
# Main: Tabs
|
| 140 |
+
# -------------------------
|
| 141 |
+
tabs = st.tabs(["Zendesk Lookup", "Jira Lookup"])
|
| 142 |
+
|
| 143 |
+
# ---- Tab 1: Zendesk ----
|
| 144 |
+
with tabs[0]:
|
| 145 |
+
st.header("Zendesk Lookup")
|
| 146 |
+
site_input = st.text_input(
|
| 147 |
+
"Site Name", placeholder="example.com", key="zendesk_site"
|
| 148 |
+
)
|
| 149 |
+
keyword_input = st.text_input(
|
| 150 |
+
"Keyword", placeholder="timeout", key="zendesk_keyword"
|
| 151 |
+
)
|
| 152 |
+
start_input = st.date_input(
|
| 153 |
+
"Created After", value=date.today() - timedelta(days=7), key="zendesk_start"
|
| 154 |
+
)
|
| 155 |
+
end_input = st.date_input("Created Before", value=date.today(), key="zendesk_end")
|
| 156 |
+
if st.button("Search Zendesk Tickets", key="zendesk_search"):
|
| 157 |
+
st.session_state.zendesk_df = search_zendesk_tickets(site_input, keyword_input)
|
| 158 |
+
|
| 159 |
+
df_z = st.session_state.zendesk_df.copy()
|
| 160 |
+
if not df_z.empty:
|
| 161 |
+
# parse & filter dates
|
| 162 |
+
df_z["Created At"] = pd.to_datetime(df_z["Created At"])
|
| 163 |
+
df_z["Updated At"] = pd.to_datetime(df_z["Updated At"])
|
| 164 |
+
mask = (df_z["Created At"].dt.date >= start_input) & (
|
| 165 |
+
df_z["Created At"].dt.date <= end_input
|
| 166 |
+
)
|
| 167 |
+
df_z = df_z.loc[mask]
|
| 168 |
+
|
| 169 |
+
# sort by Created At descending
|
| 170 |
+
df_z = df_z.sort_values("Created At", ascending=False)
|
| 171 |
+
|
| 172 |
+
# format timestamps 12-hour
|
| 173 |
+
df_z["Created At"] = (
|
| 174 |
+
df_z["Created At"].dt.strftime("%Y-%m-%d %I:%M %p").str.lower()
|
| 175 |
+
)
|
| 176 |
+
df_z["Updated At"] = (
|
| 177 |
+
df_z["Updated At"].dt.strftime("%Y-%m-%d %I:%M %p").str.lower()
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# hyperlink ID
|
| 181 |
+
base_url = f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/agent/tickets"
|
| 182 |
+
df_z["ID"] = df_z["ID"].apply(
|
| 183 |
+
lambda x: f'<a href="{base_url}/{x}" target="_blank">{x}</a>'
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# render fixed-height table including the new summary column
|
| 187 |
+
html_tbl = df_z.to_html(
|
| 188 |
+
index=False,
|
| 189 |
+
escape=False,
|
| 190 |
+
columns=[
|
| 191 |
+
"ID",
|
| 192 |
+
"Subject",
|
| 193 |
+
"Status",
|
| 194 |
+
"Created At",
|
| 195 |
+
"Updated At",
|
| 196 |
+
"OpenAI Ticket Summary",
|
| 197 |
+
],
|
| 198 |
+
)
|
| 199 |
+
scrollable = f"""
|
| 200 |
+
<div style="height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 4px;">
|
| 201 |
+
{html_tbl}
|
| 202 |
+
</div>
|
| 203 |
+
"""
|
| 204 |
+
st.markdown(scrollable, unsafe_allow_html=True)
|
| 205 |
+
|
| 206 |
+
# ---- Tab 2: Jira ----
|
| 207 |
+
with tabs[1]:
|
| 208 |
+
st.header("Jira Lookup")
|
| 209 |
+
site_input = st.text_input("Site Name", placeholder="example.com", key="jira_site")
|
| 210 |
+
keyword_input = st.text_input("Keyword", placeholder="timeout", key="jira_keyword")
|
| 211 |
+
start_input = st.date_input(
|
| 212 |
+
"Created After", value=date.today() - timedelta(days=7), key="jira_start"
|
| 213 |
+
)
|
| 214 |
+
end_input = st.date_input("Created Before", value=date.today(), key="jira_end")
|
| 215 |
+
if st.button("Search Jira Issues", key="jira_search"):
|
| 216 |
+
st.session_state.jira_df = search_jira_issues(
|
| 217 |
+
site_input, keyword_input, start_input, end_input
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
df_j = st.session_state.jira_df.copy()
|
| 221 |
+
|
| 222 |
+
if not df_j.empty:
|
| 223 |
+
# parse & sort by Created At descending
|
| 224 |
+
df_j["Created At"] = pd.to_datetime(df_j["Created At"])
|
| 225 |
+
df_j["Updated At"] = pd.to_datetime(df_j["Updated At"])
|
| 226 |
+
df_j = df_j.sort_values("Created At", ascending=False)
|
| 227 |
+
|
| 228 |
+
# 12-hour fmt with am/pm
|
| 229 |
+
df_j["Created At"] = (
|
| 230 |
+
df_j["Created At"].dt.strftime("%Y-%m-%d %I:%M %p").str.lower()
|
| 231 |
+
)
|
| 232 |
+
df_j["Updated At"] = (
|
| 233 |
+
df_j["Updated At"].dt.strftime("%Y-%m-%d %I:%M %p").str.lower()
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# hyperlink the key to the JIRA issue
|
| 237 |
+
base_jira = JIRA_URL.rstrip("/")
|
| 238 |
+
df_j["Key"] = df_j["Key"].apply(
|
| 239 |
+
lambda k: f'<a href="{base_jira}/browse/{k}" target="_blank">{k}</a>'
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# render as fixed-height, scrollable HTML table
|
| 243 |
+
html_tbl = df_j.to_html(
|
| 244 |
+
index=False,
|
| 245 |
+
escape=False,
|
| 246 |
+
columns=["Key", "Summary", "Status", "Created At", "Updated At"],
|
| 247 |
+
)
|
| 248 |
+
scrollable = f"""
|
| 249 |
+
<div style="height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 4px;">
|
| 250 |
+
{html_tbl}
|
| 251 |
+
</div>
|
| 252 |
+
"""
|
| 253 |
+
st.markdown(scrollable, unsafe_allow_html=True)
|
.gitattributes → gitattributes
RENAMED
|
File without changes
|
index.html
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width" />
|
| 6 |
-
<title>My static Space</title>
|
| 7 |
-
<link rel="stylesheet" href="style.css" />
|
| 8 |
-
</head>
|
| 9 |
-
<body>
|
| 10 |
-
<div class="card">
|
| 11 |
-
<h1>Welcome to your static Space!</h1>
|
| 12 |
-
<p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
|
| 13 |
-
<p>
|
| 14 |
-
Also don't forget to check the
|
| 15 |
-
<a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
atlassian-python-api
|
| 2 |
+
openai
|
| 3 |
+
pandas
|
| 4 |
+
requests
|
| 5 |
+
streamlit
|
style.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
body {
|
| 2 |
-
padding: 2rem;
|
| 3 |
-
font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
h1 {
|
| 7 |
-
font-size: 16px;
|
| 8 |
-
margin-top: 0;
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
p {
|
| 12 |
-
color: rgb(107, 114, 128);
|
| 13 |
-
font-size: 15px;
|
| 14 |
-
margin-bottom: 10px;
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|