chatbot2 / app.py
r00tb3's picture
Update app.py
c180ae8 verified
# 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()