|
|
|
|
|
import os, json |
|
|
import gradio as gr |
|
|
import pandas as pd |
|
|
from rapidfuzz import fuzz |
|
|
import json, uuid, datetime as dt, re |
|
|
|
|
|
|
|
|
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} |
|
|
""" |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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) |
|
|
s_label, s_score = sentiment_smart(red) |
|
|
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) |
|
|
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() |
|
|
|