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()