github-actions commited on
Commit
c5e58d7
Β·
0 Parent(s):

Deploy to Hugging Face Space

Browse files
Files changed (8) hide show
  1. app.py +204 -0
  2. collect.py +64 -0
  3. eval/eval.py +100 -0
  4. eval/eval_dataset.json +42 -0
  5. eval/results.json +5 -0
  6. rag.py +85 -0
  7. requirements.txt +15 -0
  8. triage.py +135 -0
app.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import gradio as gr
3
+ from openai import OpenAI
4
+ from collect import fetch_reviews
5
+ from triage import route_review, triage_review
6
+ from rag import init_store, add_bug, search_bugs, clear_store
7
+
8
+ init_store()
9
+
10
+ def collect_and_triage(review, api_key):
11
+ review_text = review["text"]
12
+ route_data = route_review(review_text, api_key)
13
+ route = route_data.get("route", "bug_report")
14
+
15
+ if route != "bug_report":
16
+ return None, route
17
+
18
+ similar = search_bugs(review_text, top_k=2)
19
+ structured = triage_review(review_text, api_key, similar_bugs=similar)
20
+ add_bug(structured)
21
+ return structured.get("title", ""), route
22
+
23
+ def handle_collect(app_name, max_reviews, api_key_input):
24
+ api_key = (api_key_input or "").strip()
25
+ if not api_key:
26
+ yield "OpenAI API key is required for BYOK."
27
+ return
28
+
29
+ yield f"Fetching reviews for {app_name}..."
30
+ reviews = fetch_reviews(app_name, max_reviews=int(max_reviews))
31
+ yield f"Got {len(reviews)} reviews. Triaging..."
32
+
33
+ titles = []
34
+ skipped = {"feature_request": 0, "general_complaint": 0}
35
+ for review in reviews:
36
+ title, route = collect_and_triage(review, api_key)
37
+ if route == "bug_report" and title:
38
+ titles.append(title)
39
+ elif route in skipped:
40
+ skipped[route] += 1
41
+
42
+ output = "\n".join([f"{i+1}. {t}" for i, t in enumerate(titles)])
43
+ yield (
44
+ f"Done β€” {len(titles)} bugs saved. "
45
+ f"Skipped: {skipped['feature_request']} feature request(s), "
46
+ f"{skipped['general_complaint']} general complaint(s).\n\n{output}"
47
+ )
48
+
49
+ def build_triage_output(review_text,api_key):
50
+ route_data = route_review(review_text, api_key)
51
+ route = route_data.get("route", "bug_report")
52
+
53
+ if route != "bug_report":
54
+ confidence = route_data.get("confidence", 0)
55
+ output = (
56
+ f"Route: {route} (confidence: {confidence})\n\n"
57
+ "This input is not a bug report, so it was not added to bug store."
58
+ )
59
+ return output, None
60
+
61
+ similar = search_bugs(review_text, top_k=2)
62
+ structured = triage_review(review_text, api_key, similar_bugs=similar)
63
+ add_bug(structured)
64
+
65
+ output = f"Severity: {structured.get('severity','')} | Component: {structured.get('component','')}\n\n"
66
+ output += f"Bug report:\n```json\n{json.dumps(structured, indent=2)}\n```\n\n"
67
+ output += "Similar bugs:\n"
68
+ output += "\n".join([f"- {b.get('title','')} [{b.get('severity','')}]" for b in similar])
69
+ return output, structured
70
+
71
+ def handle_triage(review_text, api_key_input):
72
+ api_key = (api_key_input or "").strip()
73
+ if not api_key:
74
+ yield "OpenAI API key is required for BYOK."
75
+ return
76
+
77
+ yield "Triaging review..."
78
+ output, structured = build_triage_output(review_text, api_key)
79
+ yield output
80
+
81
+ if not structured:
82
+ return
83
+
84
+ client = OpenAI(api_key=api_key)
85
+ stream = client.chat.completions.create(
86
+ model="gpt-4o",
87
+ max_tokens=200,
88
+ stream=True,
89
+ messages=[{
90
+ "role": "user",
91
+ "content": f"Write a 3 sentence QA incident summary:\n{json.dumps(structured, indent=2)}"
92
+ }]
93
+ )
94
+
95
+ output += "\nAI Summary:\n\n"
96
+ for chunk in stream:
97
+ output += chunk.choices[0].delta.content or ""
98
+ yield output
99
+ def build_search_output(results, query):
100
+ output = f"{len(results)} results for: {query}\n\n---\n"
101
+ output += "\n\n---\n".join([
102
+ f"{r.get('title','')}\n"
103
+ f"{r.get('severity','')} | {r.get('component','')} | {r.get('platform','')}\n"
104
+ f"{r.get('description','')}"
105
+ for r in results
106
+ ])
107
+ return output
108
+
109
+
110
+ def get_ai_summary(results, query, api_key):
111
+ client = OpenAI(api_key=api_key)
112
+ context = "\n".join([
113
+ f"- {r.get('title','')}: {r.get('description','')}"
114
+ for r in results
115
+ ])
116
+ resp = client.chat.completions.create(
117
+ model="gpt-4o",
118
+ max_tokens=150,
119
+ messages=[{
120
+ "role": "user",
121
+ "content": f"Query: {query}\nBugs:\n{context}\nSummarise in 2 sentences:"
122
+ }]
123
+ )
124
+ return resp.choices[0].message.content
125
+
126
+
127
+ def handle_search(query, api_key_input):
128
+ api_key = (api_key_input or "").strip()
129
+ if not api_key:
130
+ return "OpenAI API key is required for BYOK."
131
+
132
+ results = search_bugs(query, top_k=5)
133
+ output = build_search_output(results, query)
134
+ output += f"\n\nAI Summary:\n{get_ai_summary(results, query, api_key)}"
135
+ return output
136
+
137
+
138
+ def handle_clear_bugs():
139
+ removed = clear_store()
140
+ init_store()
141
+ return f"Cleared {removed} bug(s)."
142
+
143
+ with gr.Blocks(title="QA Bug Triage") as demo:
144
+ gr.Markdown("# QA Bug Triage Pipeline\nA modern RAG workflow for turning messy app reviews into structured, searchable QA bug intelligence..")
145
+
146
+ api_key_box = gr.Textbox(
147
+ label="OpenAI API key (BYOK)",
148
+ placeholder="sk-...",
149
+ type="password",
150
+ value=""
151
+ )
152
+
153
+ with gr.Tabs():
154
+
155
+ with gr.TabItem("1. Collect"):
156
+ app_name_box = gr.Textbox(label="App name", value="notion")
157
+ max_box = gr.Slider(5, 20, value=10, step=5, label="Max reviews")
158
+ collect_btn = gr.Button("Fetch and triage", variant="primary")
159
+ collect_out = gr.Markdown()
160
+ collect_btn.click(
161
+ handle_collect,
162
+ [app_name_box, max_box, api_key_box],
163
+ collect_out
164
+ )
165
+
166
+ with gr.TabItem("2. Triage"):
167
+ review_box = gr.Textbox(
168
+ label="Paste a review",
169
+ lines=4,
170
+ placeholder="App crashes every time I try to upload a photo..."
171
+ )
172
+ triage_btn = gr.Button("Triage", variant="primary")
173
+ triage_out = gr.Markdown()
174
+ triage_btn.click(
175
+ handle_triage,
176
+ [review_box, api_key_box],
177
+ triage_out
178
+ )
179
+
180
+ with gr.TabItem("3. Search"):
181
+ search_box = gr.Textbox(
182
+ label="Search query",
183
+ placeholder="login crash android"
184
+ )
185
+ search_btn = gr.Button("Search", variant="primary")
186
+ search_out = gr.Markdown()
187
+ search_btn.click(
188
+ handle_search,
189
+ [search_box, api_key_box],
190
+ search_out
191
+ )
192
+
193
+ with gr.TabItem("4. Clear bugs"):
194
+ clear_btn = gr.Button("Clear stored bugs", variant="stop")
195
+ clear_out = gr.Markdown()
196
+ clear_btn.click(
197
+ handle_clear_bugs,
198
+ outputs=clear_out
199
+ )
200
+
201
+
202
+ if __name__ == "__main__":
203
+ demo.launch()
204
+
collect.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from datetime import datetime
3
+
4
+ def make_review(text: str, rating: int, source: str) -> dict:
5
+ """Create a review dictionary with the given text, rating, and source."""
6
+ return {
7
+ 'text': text.strip(),
8
+ 'rating': rating,
9
+ 'source': source,
10
+ 'date': datetime.today().strftime('%Y-%m-%d %H:%M:%S')
11
+ }
12
+
13
+ APP_PACKAGES = {
14
+ "google maps": "com.google.android.apps.maps",
15
+ "facebook": "com.facebook.katana",
16
+ "instagram": "com.instagram.android",
17
+ "whatsapp": "com.whatsapp",
18
+ "snapchat": "com.snapchat.android",
19
+ "notion": "com.notion.android",
20
+ "spotify": "com.spotify.music",
21
+ "netflix": "com.netflix.mediaclient",
22
+ }
23
+
24
+ def get_package_id(app_name: str) -> str:
25
+ """Return the package ID for the given app name, or a default format if not found."""
26
+ name_lower = app_name.lower().strip()
27
+ default_package = f"com.{name_lower.replace(' ', '')}"
28
+ return APP_PACKAGES.get(name_lower, default_package)
29
+
30
+ def fetch_reviews(app_name: str, source: str = "Google Play", max_reviews: int = 21) -> list:
31
+ from google_play_scraper import reviews, Sort
32
+
33
+ package_id = get_package_id(app_name)
34
+ print(f"Fetching reviews for '{app_name}' (package: {package_id}) from {source}...")
35
+
36
+ try:
37
+ results, _ = reviews(
38
+ package_id,
39
+ lang='en',
40
+ country='us',
41
+ sort=Sort.NEWEST,
42
+ count=max_reviews
43
+ )
44
+
45
+ cleaned = [
46
+ make_review(r['content'], r.get('score', 1), "Google Play")
47
+ for r in filter(lambda item: item.get('content', '').strip(), results)
48
+ ]
49
+
50
+ unique = list({review['text'][:80]: review for review in cleaned}.values())
51
+
52
+ messages = [
53
+ f"No reviews found for '{app_name}' on {source}.",
54
+ f"Fetched {len(unique)} unique reviews for '{app_name}' from {source}.",
55
+ ]
56
+ print(messages[bool(unique)])
57
+
58
+ time.sleep(5)
59
+
60
+ return unique[:max_reviews]
61
+
62
+ except Exception as e:
63
+ print(f"Error fetching reviews for '{app_name}' from {source}: {e}")
64
+ return []
eval/eval.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import json
4
+ import sys
5
+ import argparse
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ from rag import search_bugs, init_store
13
+ from openai import OpenAI
14
+ from ragas import evaluate, EvaluationDataset, SingleTurnSample
15
+ from ragas.metrics import Faithfulness, AnswerRelevancy, ContextPrecision
16
+ from ragas.llms import LlamaIndexLLMWrapper
17
+ from llama_index.llms.openai import OpenAI as LlamaOpenAI
18
+
19
+ DATASET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "eval_dataset.json")
20
+ RESULTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.json")
21
+
22
+
23
+ def get_answer(query, contexts, api_key):
24
+ client = OpenAI(api_key=api_key)
25
+ context = "\n".join(contexts)
26
+ resp = client.chat.completions.create(
27
+ model="gpt-4o",
28
+ max_tokens=150,
29
+ messages=[{
30
+ "role": "user",
31
+ "content": f"Query: {query}\n\nContext:\n{context}\n\nAnswer in 2 sentences:"
32
+ }]
33
+ )
34
+ return resp.choices[0].message.content.strip()
35
+
36
+
37
+ def build_sample(item, api_key):
38
+ query = item["query"]
39
+ bugs = search_bugs(query, top_k=5)
40
+ contexts = [f"{b['title']}: {b['description']}" for b in bugs]
41
+ answer = get_answer(query, contexts, api_key)
42
+ print(f"query : {query}")
43
+ print(f"answer: {answer}\n")
44
+ return SingleTurnSample(
45
+ user_input=query,
46
+ response=answer,
47
+ retrieved_contexts=contexts,
48
+ reference=item["reference_answer"]
49
+ )
50
+
51
+
52
+ def run_eval(api_key):
53
+ dataset = json.load(open(DATASET))
54
+ print(f"Loaded {len(dataset)} queries\n")
55
+
56
+ init_store()
57
+
58
+ llm = LlamaOpenAI(model="gpt-4o", api_key=api_key)
59
+ evaluator_llm = LlamaIndexLLMWrapper(llm)
60
+
61
+ samples = [build_sample(item, api_key) for item in dataset]
62
+
63
+ results = evaluate(
64
+ EvaluationDataset(samples=samples),
65
+ metrics=[
66
+ Faithfulness(llm=evaluator_llm),
67
+ AnswerRelevancy(llm=evaluator_llm),
68
+ ContextPrecision(llm=evaluator_llm),
69
+ ]
70
+ )
71
+
72
+ df = results.to_pandas()
73
+
74
+ print("=" * 40)
75
+ print("RAGAS RESULTS")
76
+ print("=" * 40)
77
+ print(f"Faithfulness : {df['faithfulness'].mean():.3f}")
78
+ print(f"Answer Relevancy : {df['answer_relevancy'].mean():.3f}")
79
+ print(f"Context Precision : {df['context_precision'].mean():.3f}")
80
+ print("=" * 40)
81
+
82
+ json.dump({
83
+ "faithfulness": round(float(df["faithfulness"].mean()), 3),
84
+ "answer_relevancy": round(float(df["answer_relevancy"].mean()), 3),
85
+ "context_precision": round(float(df["context_precision"].mean()), 3),
86
+ }, open(RESULTS, "w"), indent=2)
87
+
88
+ print("Saved to eval/results.json")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ parser = argparse.ArgumentParser()
93
+ parser.add_argument("--api-key", default=os.getenv("OPENAI_API_KEY"))
94
+ args = parser.parse_args()
95
+
96
+ if not args.api_key:
97
+ print("Error: OPENAI_API_KEY not found. Set it in .env or pass --api-key")
98
+ sys.exit(1)
99
+
100
+ run_eval(args.api_key)
eval/eval_dataset.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "query": "app crashes on login",
4
+ "reference_answer": "The app crashes when users try to log in, preventing access."
5
+ },
6
+ {
7
+ "query": "slow performance on older devices",
8
+ "reference_answer": "The app experiences slow performance and lag on older devices, affecting usability."
9
+ },
10
+ {
11
+ "query": "UI glitches on dark mode",
12
+ "reference_answer": "Users report UI glitches and misaligned elements when using the app in dark mode."
13
+ },
14
+ {
15
+ "query": "frequent crashes during video playback",
16
+ "reference_answer": "The app frequently crashes when users attempt to play videos, disrupting the experience."
17
+ },
18
+ {
19
+ "query": "inability to save settings",
20
+ "reference_answer": "Users are unable to save their settings, leading to frustration and repeated configuration."
21
+ },
22
+ {
23
+ "query": "notifications not working",
24
+ "reference_answer": "Users report that they are not receiving notifications from the app, missing important updates."
25
+ },
26
+ {
27
+ "query": "app freezes on startup",
28
+ "reference_answer": "The app freezes and becomes unresponsive when users try to start it, preventing access."
29
+ },
30
+ {
31
+ "query": "battery drain issue",
32
+ "reference_answer": "Users experience significant battery drain when using the app, reducing device longevity."
33
+ },
34
+ {
35
+ "query": "incompatibility with latest OS version",
36
+ "reference_answer": "The app is incompatible with the latest OS version, causing crashes and functionality issues."
37
+ },
38
+ {
39
+ "query": "problems with in-app purchases",
40
+ "reference_answer": "Users encounter issues with in-app purchases, such as failed transactions and missing items."
41
+ }
42
+ ]
eval/results.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "faithfulness": 0.292,
3
+ "answer_relevancy": 0.868,
4
+ "context_precision": 0.020
5
+ }
rag.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import chromadb
3
+ from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
4
+ from rank_bm25 import BM25Okapi
5
+
6
+ _collection = None
7
+ _all_bugs = []
8
+
9
+ def init_store():
10
+ global _collection, _all_bugs
11
+
12
+ client = chromadb.PersistentClient(path="./chroma_db")
13
+ _collection = client.get_or_create_collection(
14
+ name="bug_reports",
15
+ embedding_function=DefaultEmbeddingFunction()
16
+ )
17
+
18
+ data = _collection.get(include=["metadatas"])
19
+ _all_bugs = data["metadatas"] or []
20
+
21
+ print(f"[rag] Ready - {len(_all_bugs)} bugs loaded")
22
+
23
+ def add_bug(bug: dict):
24
+ init_store()
25
+
26
+ bug_id = bug.get("bug_id") or f"BUG-{uuid.uuid4().hex[:6].upper()}"
27
+ text = f"{bug.get('title', '')}. {bug.get('description', '')}"
28
+
29
+ metadata = {
30
+ "bug_id": bug_id,
31
+ "title": str(bug.get("title", "")),
32
+ "severity": str(bug.get("severity", "unknown")),
33
+ "component": str(bug.get("component", "unknown")),
34
+ "platform": str(bug.get("platform", "unknown")),
35
+ "frequency": str(bug.get("frequency_estimate", "unknown")),
36
+ "description": str(bug.get("description", ""))[:400],
37
+ }
38
+
39
+ _collection.upsert(ids=[bug_id], documents=[text], metadatas=[metadata])
40
+ _all_bugs.append(metadata)
41
+
42
+ def search_bugs(query: str, top_k: int = 5):
43
+ init_store()
44
+
45
+ results = _collection.query(query_texts=[query], n_results=top_k)
46
+ sem_bugs = results.get("metadatas", [[]])[0]
47
+
48
+ if not _all_bugs:
49
+ return sem_bugs
50
+
51
+ corpus = [f"{bug.get('title', '')}. {bug.get('description', '')}" for bug in _all_bugs]
52
+ tokenized_corpus = [doc.split() for doc in corpus]
53
+ bm25 = BM25Okapi(tokenized_corpus)
54
+ bm25_scores = bm25.get_scores(query.split())
55
+ bm25_indices = sorted(range(len(_all_bugs)), key=lambda i: bm25_scores[i], reverse=True)[:top_k]
56
+ bm25_bugs = [_all_bugs[i] for i in bm25_indices]
57
+
58
+ sem_rank = {bug["bug_id"]: idx + 1 for idx, bug in enumerate(sem_bugs)}
59
+ bm25_rank = {bug["bug_id"]: idx + 1 for idx, bug in enumerate(bm25_bugs)}
60
+ bug_by_id = {bug["bug_id"]: bug for bug in sem_bugs + bm25_bugs}
61
+ candidate_ids = set(sem_rank) | set(bm25_rank)
62
+
63
+ rrf_k = 60
64
+ default_rank = 10**6
65
+ ranked_ids = sorted(
66
+ candidate_ids,
67
+ key=lambda bug_id: (1 / (rrf_k + sem_rank.get(bug_id, default_rank))) + (1 / (rrf_k + bm25_rank.get(bug_id, default_rank))),
68
+ reverse=True,
69
+ )
70
+
71
+ return [bug_by_id[bug_id] for bug_id in ranked_ids[:top_k]]
72
+
73
+
74
+ def clear_store():
75
+ global _all_bugs
76
+
77
+ init_store()
78
+ data = _collection.get()
79
+ ids = data.get("ids", []) or []
80
+
81
+ if ids:
82
+ _collection.delete(ids=ids)
83
+
84
+ _all_bugs = []
85
+ return len(ids)
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openai
2
+ gradio
3
+ numpy
4
+ llama-index
5
+ llama-index-vector-stores-chroma
6
+ llama-index-retrievers-bm25
7
+ llama-index-llms-openai
8
+ llama-index-postprocessor-cohere-rerank
9
+ chromadb
10
+ ragas
11
+ beautifulsoup4
12
+ requests
13
+ cohere
14
+ google-play-scraper
15
+ rank-bm25
triage.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ from openai import OpenAI
4
+
5
+ # ── system prompt ─────────────────────────────────────────────────────────────
6
+
7
+ SYSTEM_PROMPT = """You are a bug triage assistant who reads customer reviews and extracts structured bug reports.
8
+
9
+ Your output must always be valid with these exact fields:
10
+
11
+ {
12
+ "title": "A concise title summarizing the bug",
13
+ "severity": "One of 'critical', 'major', 'minor', or 'trivial'",
14
+ "component": "The app component affected by the bug (e.g., 'login', 'payment', 'UI')",
15
+ "platform": "The platform where the bug occurs (e.g., 'Android', 'iOS')",
16
+ "frequency_estimate": "An estimate of how often the bug occurs (e.g., 'always', 'often', 'sometimes', 'rarely')",
17
+ "symptom": "A detailed description of the symptoms experienced by users",
18
+ "user_impact": "A description of how the bug impacts users (e.g., 'prevents login', 'causes crashes', 'leads to data loss')",
19
+ "recommendation_label": "A recommended action for the development team (e.g., 'investigate immediately', 'schedule for next release', 'monitor user feedback')",
20
+ }
21
+
22
+ severity guide:
23
+ - critical: The bug causes complete failure of a core feature, data loss, or security vulnerabilities. It severely impacts user experience and requires immediate attention.
24
+ - high: The bug significantly impairs functionality or causes frequent crashes, but does not result in complete failure. It should be addressed as soon as possible.
25
+ - medium: The bug causes noticeable issues or inconveniences but has workarounds available. It should be fixed in a timely manner.
26
+ - low: The bug has minimal impact on functionality or user experience, such as minor UI glitches or typos. It can be scheduled for future releases.
27
+
28
+ Rules:
29
+ - Return only the JSON object with the specified fields. Do not include any explanations, apologies, or additional text.
30
+ - If review is vague, make your best guess from context.
31
+ - Never leave a field empty β€” use Unknown or Other as fallback."""
32
+
33
+
34
+ def _strip_code_fences(raw: str) -> str:
35
+ return raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
36
+
37
+
38
+ def _loads_or_default(raw: str, default: dict, error_prefix: str) -> dict:
39
+ try:
40
+ return json.loads(raw)
41
+ except json.JSONDecodeError:
42
+ print(f"{error_prefix}: {raw}")
43
+ return default
44
+
45
+
46
+ # ── function 1: route_review ──────────────────────────────────────────────────
47
+
48
+ def route_review(review_text: str, api_key: str) -> dict:
49
+ client = OpenAI(api_key=api_key)
50
+
51
+ response = client.chat.completions.create(
52
+ model="gpt-4o",
53
+ max_tokens=50,
54
+ messages=[
55
+ {
56
+ "role": "user",
57
+ "content": f"""Classify this review into exactly one category:
58
+ - bug_report : describes a crash, freeze, error, or malfunction
59
+ - feature_request : asks for something new or improved
60
+ - general_complaint : vague dissatisfaction, no specific technical issue
61
+
62
+ Reply with JSON only, no explanation:
63
+ {{"route": "...", "confidence": 0.0}}
64
+
65
+ Review: {review_text}"""
66
+ }
67
+ ]
68
+ )
69
+
70
+ raw = response.choices[0].message.content.strip()
71
+ default = {"route": "bug_report", "confidence": 0.8}
72
+ return _loads_or_default(raw, default, "Failed to parse routing response")
73
+
74
+
75
+ # ── function 2: triage_review ─────────────────────────────────────────────────
76
+
77
+ def triage_review(review_text: str, api_key: str, similar_bugs: list = None) -> dict:
78
+ client = OpenAI(api_key=api_key)
79
+
80
+ examples = [
81
+ {
82
+ "title": bug.get("title", ""),
83
+ "severity": bug.get("severity", ""),
84
+ "component": bug.get("component", ""),
85
+ "platform": bug.get("platform", ""),
86
+ "frequency_estimate": bug.get("frequency", ""),
87
+ }
88
+ for bug in (similar_bugs or [])[:2]
89
+ ]
90
+ few_shot_text = "".join([f"```json\n{json.dumps(example, indent=2)}\n```\n" for example in examples])
91
+
92
+ user_message = f"""Triage this customer review and return the JSON bug report.
93
+ Here are some examples of previously triaged bugs:
94
+
95
+ {few_shot_text}
96
+ Review:
97
+ \"\"\"{review_text}\"\"\"
98
+
99
+ JSON output:"""
100
+
101
+ fallback = {
102
+ "title": "Needs manual review",
103
+ "severity": "medium",
104
+ "component": "Other",
105
+ "platform": "Unknown",
106
+ "frequency_estimate": "unknown",
107
+ "symptom": review_text[:150],
108
+ "user_impact": "Unknown β€” review manually",
109
+ "recommended_label": "P3 - minor",
110
+ }
111
+
112
+ try:
113
+ response = client.chat.completions.create(
114
+ model="gpt-4o",
115
+ max_tokens=500,
116
+ messages=[
117
+ {
118
+ "role": "system",
119
+ "content": SYSTEM_PROMPT,
120
+ },
121
+ {
122
+ "role": "user",
123
+ "content": user_message,
124
+ },
125
+ ],
126
+ )
127
+ raw = _strip_code_fences(response.choices[0].message.content)
128
+ structured = _loads_or_default(raw, fallback, "[triage] parse error")
129
+ except Exception as e:
130
+ print(f"[triage] error: {e}")
131
+ structured = fallback
132
+
133
+ structured["bug_id"] = f"BUG-{uuid.uuid4().hex[:6].upper()}"
134
+ structured["description"] = structured.get("symptom", "")
135
+ return structured