# 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":"