File size: 10,545 Bytes
9365edb c180ae8 9365edb c180ae8 9365edb c180ae8 9365edb c180ae8 9365edb c180ae8 9365edb c180ae8 9365edb |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 |
# app.py — super-minimal ProdBot (Chat + Backlog only) for Hugging Face Spaces
import os, json
import gradio as gr
import pandas as pd
from rapidfuzz import fuzz
import json, uuid, datetime as dt, re
# ---- OpenAI helper (optional; falls back if no key) ----
def llm_call(messages, model="gpt-4o-mini", temperature=0.2, max_tokens=300):
"""Returns text from OpenAI Chat Completions or raises."""
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise RuntimeError("OPENAI_API_KEY missing")
from openai import OpenAI
client = OpenAI(api_key=api_key)
resp = client.chat.completions.create(
model=model, temperature=temperature, max_tokens=max_tokens, messages=messages
)
return resp.choices[0].message.content
STATE = {"features": [], "feedback": []}
def now_iso(): return dt.datetime.utcnow().isoformat() + "Z"
def mk_id(prefix="FB"): return f"{prefix}{uuid.uuid4().hex[:8]}"
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
PHONE_RE = re.compile(r"\+?\d[\d\s\-()]{7,}\d")
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
def redact(text: str) -> str:
text = EMAIL_RE.sub("[REDACTED_EMAIL]", text)
text = PHONE_RE.sub("[REDACTED_PHONE]", text)
text = IP_RE.sub("[REDACTED_IP]", text)
return text
POS_WORDS = {"love","great","awesome","amazing","nice","helpful","good"}
NEG_WORDS = {"slow","bad","hate","broken","fail","fails","error","crash","confusing","hard","bug"}
def tiny_sentiment(text: str):
t = text.lower()
pos = sum(w in t for w in POS_WORDS)
neg = sum(w in t for w in NEG_WORDS)
score = (pos - neg)
label = "positive" if score>0 else "negative" if score<0 else "neutral"
score = max(-1, min(1, score/3))
return label, round(score, 2)
def sentiment_smart(text: str):
"""
Try OpenAI sentiment; on error, fall back to tiny_sentiment.
Returns tuple: (label, score)
"""
try:
sys = {"role": "system", "content": (
"Return sentiment for product feedback. "
"Label in {positive, neutral, negative}. Score in [-1,1]. "
"Return ONLY JSON: {\"label\":\"...\",\"score\":-1..1}"
)}
user = {"role": "user", "content": text}
out = llm_call([sys, user])
data = json.loads(out)
lbl = data.get("label")
sc = float(data.get("score", 0))
if lbl in {"positive","neutral","negative"} and -1 <= sc <= 1:
return lbl, round(sc, 2)
except Exception:
pass
return tiny_sentiment(text)
def seed_demo():
if STATE["features"]: return
now = now_iso()
STATE["features"] = [
{"feature_id":"F101","title":"Invoice PDF export","description":"Export invoices as PDF",
"tags":["billing","export"],"status":"planned",
"demand_signals":{"feedback_count":14,"unique_users":11,"segments":{"pro":6,"enterprise":5}},
"priority_score":None,"subscribers":["finance@corp"],"updated_at":now},
{"feature_id":"F102","title":"Usage-based billing","description":"Metered billing by API calls",
"tags":["billing","pricing"],"status":"idea",
"demand_signals":{"feedback_count":8,"unique_users":8,"segments":{"enterprise":8}},
"priority_score":None,"subscribers":[],"updated_at":now},
{"feature_id":"F201","title":"Offline editing","description":"Work without internet, sync later",
"tags":["editor","offline","mobile"],"status":"planned",
"demand_signals":{"feedback_count":12,"unique_users":10,"segments":{"free":4,"pro":4,"enterprise":2}},
"priority_score":None,"subscribers":["pm_editor@corp"],"updated_at":now},
{"feature_id":"F202","title":"Comment threading","description":"Nested comments in editor",
"tags":["editor","collaboration","ux"],"status":"in_development",
"demand_signals":{"feedback_count":18,"unique_users":15,"segments":{"free":5,"pro":6,"enterprise":4}},
"priority_score":None,"subscribers":[],"updated_at":now},
{"feature_id":"F301","title":"Slack integration","description":"Notify Slack channels on changes",
"tags":["collaboration","integration"],"status":"beta",
"demand_signals":{"feedback_count":9,"unique_users":7,"segments":{"pro":5,"enterprise":2}},
"priority_score":None,"subscribers":["cs_team@corp"],"updated_at":now},
{"feature_id":"F302","title":"Role-based access control","description":"Viewer/Editor/Admin roles",
"tags":["collaboration","security"],"status":"planned",
"demand_signals":{"feedback_count":20,"unique_users":16,"segments":{"enterprise":16}},
"priority_score":None,"subscribers":["security@corp"],"updated_at":now}
]
samples = [
("u1","in_app","web",None,"Please add PDF invoice export."),
("u2","email","web","1.2.0","Our finance team needs usage-based billing for API customers."),
("u3","in_app","ios","3.0","I want to edit docs offline during flights."),
("u4","survey","web",None,"Comment threads would make discussions much easier."),
("u5","chat","web",None,"Slack integration is great but needs per-channel settings."),
("u6","email","web","1.3.0","We require role-based access control for compliance."),
("u7","in_app","android","2.5","Offline editing would be a lifesaver."),
("u8","cs_transcript","web",None,"Customer asked about usage-based billing again."),
("u9","in_app","web",None,"PDF invoices still missing; blocker for accounting."),
("u10","in_app","desktop","2.2","Need nested comments inside editor like Google Docs.")
]
for s in samples:
STATE["feedback"].append({
"id": mk_id("FB"), "user_id": s[0], "channel": s[1], "product_area": None,
"platform": s[2], "version": s[3], "text": redact(s[4]),
"sentiment_label": tiny_sentiment(s[4])[0],
"sentiment_score": tiny_sentiment(s[4])[1],
"language": None, "duplicates_of": None, "linked_feature_ids": [],
"created_at": now_iso()
})
def classify_smart(text: str):
"""
Try OpenAI classifier; on any error, fall back to rule-based simple_classify.
Returns dict: {"label": "...", "confidence": float}
"""
# 1) Try OpenAI
try:
sys = {"role": "system", "content": (
"Classify the user's message for a product team.\n"
"Labels: feedback, bug, feature_request, support, other.\n"
"Return ONLY this JSON: "
'{"label":"<label>","confidence":<0..1>}\n'
)}
user = {"role": "user", "content": text}
out = llm_call([sys, user])
data = json.loads(out)
if "label" in data and "confidence" in data:
return data
except Exception:
pass
# 2) Fallback: your existing heuristic
t = text.lower()
if any(k in t for k in ["fail","error","crash","broken","bug"]): label="bug"
elif any(k in t for k in ["feature","add","please","need","would like","would love","request"]): label="feature_request"
elif any(k in t for k in ["how do i","help","docs","documentation","guide"]): label="support"
else: label="feedback"
return {"label": label, "confidence": 0.7}
def fuzzy_link(text: str):
scores=[]
for f in STATE["features"]:
blob = f"{f['title']} {f.get('description','')} {' '.join(f.get('tags',[]))}"
try:
from rapidfuzz import fuzz as _fuzz
sc = max(_fuzz.token_sort_ratio(text, blob), _fuzz.partial_ratio(text, blob))/100.0
except Exception:
sc = 0.0
scores.append((f["feature_id"], sc))
scores.sort(key=lambda x: x[1], reverse=True)
linked = [fid for fid, sc in scores if sc >= 0.60][:3]
confs = {fid: round(sc,2) for fid, sc in scores[:3]}
return linked, confs
def chat_fn(history, message):
message = message.strip()
if not message: return history, ""
red = redact(message)
cls = classify_smart(red) # uses OpenAI if available
s_label, s_score = sentiment_smart(red) # uses OpenAI if available
fb = {"id": mk_id("FB"), "user_id":"anon", "channel":"chat","product_area":None,
"platform":"unknown","version":None,"text":red,"sentiment_label":s_label,
"sentiment_score":s_score,"language":None,"duplicates_of":None,
"linked_feature_ids":[],"created_at": now_iso()}
STATE["feedback"].append(fb)
linked, confs = fuzzy_link(red); fb["linked_feature_ids"] = linked
if linked:
titles = [next(f["title"] for f in STATE["features"] if f["feature_id"]==fid) for fid in linked]
reply = f"Thanks! I linked your message to: {', '.join(titles)}."
else:
reply = "Thanks! I couldn't confidently link this. File as a new idea?"
reply += f"\n\n[rationale] label={cls['label']} ({cls['confidence']}); sentiment={s_label}({s_score}); link_conf={confs}"
return (history or []) + [(message, reply)], ""
def tables(filter_text):
feats = STATE["features"]; fbs = STATE["feedback"]
if filter_text:
q = filter_text.lower()
feats = [f for f in feats if q in f["title"].lower() or any(q in t.lower() for t in f.get("tags",[]))]
fbs = [x for x in fbs if q in x["text"].lower()]
return pd.DataFrame(feats), pd.DataFrame(fbs)
with gr.Blocks(title="ProdBot (Minimal)") as demo:
gr.Markdown("# ProdBot (Minimal)\nCollect feedback ➜ auto-link to features.")
with gr.Tabs():
with gr.Tab("Chat"):
chat = gr.Chatbot(height=360) # tuple mode (avoids 'metadata is required')
inp = gr.Textbox(placeholder="Share feedback (e.g., 'Please add PDF invoice export')", label="Message")
def on_submit(h, m): return chat_fn(h or [], m)
inp.submit(on_submit, [chat, inp], [chat, inp])
with gr.Tab("Backlog"):
filt = gr.Textbox(placeholder="Filter by title/tag/text, e.g., 'billing'", label="Filter")
feat_df = gr.Dataframe(label="Features", interactive=False, wrap=True)
fb_df = gr.Dataframe(label="Feedback", interactive=False, wrap=True)
refresh = gr.Button("Refresh tables")
refresh.click(tables, [filt], [feat_df, fb_df])
filt.submit(tables, [filt], [feat_df, fb_df])
def _on_load():
seed_demo()
df1, df2 = tables("")
return df1, df2
demo.load(_on_load, None, [feat_df, fb_df])
if __name__ == "__main__":
seed_demo()
demo.launch()
|