WebashalarForML commited on
Commit
fee4aa3
·
verified ·
1 Parent(s): 47df4ee

Upload 6 files

Browse files
Files changed (5) hide show
  1. app.py +668 -112
  2. templates/batch.html +260 -273
  3. templates/index.html +254 -391
  4. templates/landing.html +132 -251
  5. utils/agents.py +1553 -0
app.py CHANGED
@@ -1,68 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import urllib.parse
2
  import math
3
- import requests
4
  import re
5
- from flask import Flask, request, render_template, jsonify
 
 
 
 
6
  from google_play_scraper import reviews, Sort, search, app as app_info
 
 
 
7
 
8
  app = Flask(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- # --- The Rock-Solid Old App Logic ---
11
  def extract_app_id(url_or_name: str) -> str:
12
  url_or_name = url_or_name.strip()
13
-
14
- # 1. If it's a Play Store URL, parse out the 'id' param safely
15
  if "play.google.com" in url_or_name:
16
  parsed = urllib.parse.urlparse(url_or_name)
17
- query_params = urllib.parse.parse_qs(parsed.query)
18
- if 'id' in query_params:
19
- return query_params['id'][0]
20
-
21
- # 2. If it's a raw package name (e.g. com.whatsapp) with no spaces
22
  if "." in url_or_name and " " not in url_or_name:
23
  return url_or_name
24
-
25
  return ""
26
 
 
27
  def scrape_store_ids(query: str, n_hits: int = 5):
28
- """Directly scrape Play Store search results page to get valid package IDs."""
29
  try:
30
  url = f"https://play.google.com/store/search?q={urllib.parse.quote(query)}&c=apps"
31
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"}
32
  resp = requests.get(url, headers=headers, timeout=10)
33
- if resp.status_code != 200: return []
34
-
35
- # Regex to find package names like details?id=com.something
36
  pids = re.findall(r'details\?id=([a-zA-Z0-9._]+)', resp.text)
37
-
38
- # Remove duplicates and known junk
39
- unique_pids = []
40
  for p in pids:
41
- if p not in unique_pids and "None" not in p:
42
- unique_pids.append(p)
43
- return unique_pids[:n_hits]
44
- except:
45
  return []
46
 
 
47
  def serialize_review(r: dict) -> dict:
48
- """Return all useful fields from a review, with dates serialized to ISO strings."""
49
  return {
50
  "reviewId": r.get("reviewId", ""),
51
  "userName": r.get("userName", ""),
52
- "userImage": r.get("userImage", ""), # avatar URL
53
  "content": r.get("content", ""),
54
- "score": r.get("score", 0), # 1-5 stars
55
- "thumbsUpCount": r.get("thumbsUpCount", 0), # helpful votes
56
- "reviewCreatedVersion": r.get("reviewCreatedVersion", ""), # app version when review was written
57
- "at": r["at"].isoformat() if r.get("at") else "", # review date
58
- "replyContent": r.get("replyContent", "") or "", # developer reply text
59
- "repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "", # dev reply date
60
  }
61
 
 
62
  def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
63
- """Core helper to fetch reviews for a single app ID."""
64
  info = app_info(app_id, lang='en', country='us')
65
-
66
  sort_map = {
67
  'MOST_RELEVANT': Sort.MOST_RELEVANT,
68
  'NEWEST': Sort.NEWEST,
@@ -78,69 +98,600 @@ def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
78
  reverse=True
79
  )
80
 
81
- per_bucket = math.ceil(review_limit_val(review_count) / len(star_filters))
82
- all_reviews = []
83
- seen_ids = set()
84
 
85
  for star in star_filters:
86
  result, _ = reviews(
87
- app_id,
88
- lang='en',
89
- country='us',
90
- sort=selected_sort,
91
- count=per_bucket,
92
  filter_score_with=star,
93
  )
94
  for r in result:
95
  rid = r.get('reviewId', '')
96
  if rid not in seen_ids:
97
  seen_ids.add(rid)
98
- serialized = serialize_review(r)
99
- serialized['appTitle'] = info['title']
100
- serialized['appId'] = app_id
101
- all_reviews.append(serialized)
102
-
103
  return info, all_reviews
104
 
105
- def review_limit_val(count):
106
- try: return int(count)
107
- except: return 150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  @app.route('/scrape', methods=['POST'])
110
  def scrape():
111
  try:
112
- data = request.json
113
- identifier = data.get('identifier', '').strip()
114
- count_type = data.get('review_count_type', 'fixed')
115
- count = 100000 if count_type == 'all' else data.get('review_count', 150)
116
-
117
  app_id = extract_app_id(identifier)
118
  if not app_id:
119
- # Fallback 1: Library search
120
  results = search(identifier, lang="en", country="us", n_hits=1)
121
  if results and results[0].get('appId'):
122
  app_id = results[0]['appId']
123
  else:
124
- # Fallback 2: Direct HTML scrape (Rock-solid)
125
  pids = scrape_store_ids(identifier, n_hits=1)
126
- if pids: app_id = pids[0]
127
- else: return jsonify({"error": f"App '{identifier}' not found"}), 404
 
 
128
 
129
  info, all_reviews = fetch_app_reviews(
130
- app_id, count, data.get('sort_order'), data.get('star_ratings')
131
- )
132
 
133
  return jsonify({
134
  "app_info": {
135
- "title": info['title'],
136
- "icon": info['icon'],
137
- "score": info['score'],
138
  "reviews": info['reviews'],
139
- "appId": app_id,
140
  },
141
  "reviews": all_reviews,
142
  })
143
-
144
  except Exception as e:
145
  return jsonify({"error": str(e)}), 500
146
 
@@ -148,116 +699,121 @@ def scrape():
148
  @app.route('/find-apps', methods=['POST'])
149
  def find_apps():
150
  try:
151
- data = request.json
152
- query = data.get('query', '').strip()
153
  app_count = int(data.get('app_count', 10))
154
-
155
- # Using the robust scraper to get IDs
156
- app_ids = scrape_store_ids(query, n_hits=app_count)
157
-
158
  if not app_ids:
159
- # Fallback to library
160
- hits = search(query, lang="en", country="us", n_hits=app_count)
161
  app_ids = [h['appId'] for h in hits if h.get('appId')]
162
-
163
  results = []
164
  for aid in app_ids:
165
  try:
166
  info = app_info(aid, lang='en', country='us')
167
  results.append({
168
- "appId": aid,
169
- "title": info['title'],
170
- "icon": info['icon'],
171
- "score": info['score'],
172
- "developer": info.get('developer', 'Unknown'),
173
- "installs": info.get('installs', '0+')
174
  })
175
- except: continue
176
-
 
177
  return jsonify({"results": results})
178
  except Exception as e:
179
  return jsonify({"error": str(e)}), 500
180
 
 
181
  @app.route('/scrape-batch', methods=['POST'])
182
  def scrape_batch():
183
  try:
184
- data = request.json
185
- app_ids = data.get('app_ids', [])
186
- count_type = data.get('review_count_type', 'fixed')
187
  reviews_per_app = 100000 if count_type == 'all' else int(data.get('reviews_per_app', 100))
188
-
189
  if not app_ids:
190
  return jsonify({"error": "No app IDs provided"}), 400
191
-
192
- batch_results = []
193
- all_combined_reviews = []
194
-
195
  for app_id in app_ids:
196
  try:
197
  info, app_reviews = fetch_app_reviews(
198
- app_id, reviews_per_app, data.get('sort_order'), data.get('star_ratings')
199
- )
200
  batch_results.append({
201
  "title": info['title'],
202
- "icon": info['icon'],
203
  "score": info['score'],
204
- "appId": app_id
205
  })
206
- all_combined_reviews.extend(app_reviews)
207
- except:
208
  continue
209
-
210
- return jsonify({
211
- "apps": batch_results,
212
- "reviews": all_combined_reviews
213
- })
214
  except Exception as e:
215
  return jsonify({"error": str(e)}), 500
216
 
217
 
218
-
219
  @app.route("/search-suggestions", methods=["POST"])
220
  def search_suggestions():
221
- """Return top app matches for a keyword — used by the UI search dropdown."""
222
  try:
223
- query = (request.json or {}).get("query", "").strip()
224
  if not query or len(query) < 2:
225
  return jsonify({"results": []})
226
 
227
  hits = search(query, lang="en", country="us", n_hits=6)
228
  results = []
229
  for h in hits:
230
- aid = h.get("appId", "")
231
  if not aid or aid == "None" or "." not in aid:
232
  continue
233
  results.append({
234
  "appId": aid,
235
  "storeUrl": f"https://play.google.com/store/apps/details?id={aid}",
236
- "title": h.get("title", ""),
237
- "icon": h.get("icon", ""),
238
  "score": round(h.get("score") or 0, 1),
239
- "developer": h.get("developer", ""),
240
- "installs": h.get("installs", ""),
241
  })
242
-
243
  return jsonify({"results": results[:5]})
244
  except Exception as e:
245
  return jsonify({"error": str(e)}), 500
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  @app.route('/scraper')
249
  def scraper():
250
  return render_template('index.html')
251
 
252
-
253
  @app.route('/batch')
254
  def batch():
255
  return render_template('batch.html')
256
 
257
-
258
  @app.route('/')
259
  def landing():
260
  return render_template('landing.html')
261
 
 
262
  if __name__ == "__main__":
263
  app.run(host="0.0.0.0", debug=True, port=7860)
 
1
+ """
2
+ PlayPulse Intelligence — Flask App (v2)
3
+ ─────────────────────────────────────────
4
+ Key improvements over v1
5
+ • Chat has conversation memory (per session, server-side deque)
6
+ • Intent router is enum-strict + falls back properly
7
+ • 6 inline chat tools (no agent needed for simple queries)
8
+ • Agent is one of those tools — called only for deep analysis
9
+ • /chat returns structured payload: reply + optional table / chart_data / agent_data
10
+ • "tabular format" requests produce real table JSON the frontend can render
11
+ """
12
+
13
  import urllib.parse
14
  import math
 
15
  import re
16
+ import json
17
+ import requests
18
+ from collections import deque, defaultdict
19
+ from datetime import datetime
20
+ from flask import Flask, request, render_template, jsonify, session
21
  from google_play_scraper import reviews, Sort, search, app as app_info
22
+ import pandas as pd
23
+ from utils.agents import run_agent, build_llm
24
+ import os
25
 
26
  app = Flask(__name__)
27
+ app.secret_key = os.getenv("FLASK_SECRET", "playpulse-secret-2026")
28
+
29
+ # ── Per-session conversation memory (server-side, max 20 turns) ───────────
30
+ # key: session_id → deque of {"role": "user"|"assistant", "content": str}
31
+ _CONV_MEMORY: dict[str, deque] = defaultdict(lambda: deque(maxlen=20))
32
+
33
+ MAX_HISTORY_FOR_LLM = 6 # last N turns sent to LLM for context
34
+
35
+
36
+ # ═══════════════════════════════════════════════════════════════════════════
37
+ # SCRAPER HELPERS (unchanged from v1)
38
+ # ═══════════════════════════════════════════════════════════════════════════
39
 
 
40
  def extract_app_id(url_or_name: str) -> str:
41
  url_or_name = url_or_name.strip()
 
 
42
  if "play.google.com" in url_or_name:
43
  parsed = urllib.parse.urlparse(url_or_name)
44
+ qp = urllib.parse.parse_qs(parsed.query)
45
+ if 'id' in qp:
46
+ return qp['id'][0]
 
 
47
  if "." in url_or_name and " " not in url_or_name:
48
  return url_or_name
 
49
  return ""
50
 
51
+
52
  def scrape_store_ids(query: str, n_hits: int = 5):
 
53
  try:
54
  url = f"https://play.google.com/store/search?q={urllib.parse.quote(query)}&c=apps"
55
+ headers = {"User-Agent": "Mozilla/5.0"}
56
  resp = requests.get(url, headers=headers, timeout=10)
57
+ if resp.status_code != 200:
58
+ return []
 
59
  pids = re.findall(r'details\?id=([a-zA-Z0-9._]+)', resp.text)
60
+ unique: list[str] = []
 
 
61
  for p in pids:
62
+ if p not in unique and "None" not in p:
63
+ unique.append(p)
64
+ return unique[:n_hits]
65
+ except Exception:
66
  return []
67
 
68
+
69
  def serialize_review(r: dict) -> dict:
 
70
  return {
71
  "reviewId": r.get("reviewId", ""),
72
  "userName": r.get("userName", ""),
73
+ "userImage": r.get("userImage", ""),
74
  "content": r.get("content", ""),
75
+ "score": r.get("score", 0),
76
+ "thumbsUpCount": r.get("thumbsUpCount", 0),
77
+ "reviewCreatedVersion": r.get("reviewCreatedVersion", ""),
78
+ "at": r["at"].isoformat() if r.get("at") else "",
79
+ "replyContent": r.get("replyContent", "") or "",
80
+ "repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "",
81
  }
82
 
83
+
84
  def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
 
85
  info = app_info(app_id, lang='en', country='us')
 
86
  sort_map = {
87
  'MOST_RELEVANT': Sort.MOST_RELEVANT,
88
  'NEWEST': Sort.NEWEST,
 
98
  reverse=True
99
  )
100
 
101
+ per_bucket = math.ceil(_review_limit(review_count) / len(star_filters))
102
+ all_reviews: list[dict] = []
103
+ seen_ids: set[str] = set()
104
 
105
  for star in star_filters:
106
  result, _ = reviews(
107
+ app_id, lang='en', country='us',
108
+ sort=selected_sort, count=per_bucket,
 
 
 
109
  filter_score_with=star,
110
  )
111
  for r in result:
112
  rid = r.get('reviewId', '')
113
  if rid not in seen_ids:
114
  seen_ids.add(rid)
115
+ s = serialize_review(r)
116
+ s['appTitle'] = info['title']
117
+ s['appId'] = app_id
118
+ all_reviews.append(s)
119
+
120
  return info, all_reviews
121
 
122
+
123
+ def _review_limit(count):
124
+ try:
125
+ return int(count)
126
+ except Exception:
127
+ return 150
128
+
129
+
130
+ # ═══════════════════════════════════════════════════════════════════════════
131
+ # INLINE CHAT TOOLS (fast, no heavy agent needed for simple queries)
132
+ # ═══════════════════════════════════════════════════════════════════════════
133
+
134
+ def _tool_rating_breakdown(df: pd.DataFrame) -> dict:
135
+ """Star rating distribution across all reviews."""
136
+ dist = df["score"].value_counts().sort_index()
137
+ total = max(1, len(df))
138
+ rows = [
139
+ {
140
+ "Stars": f"{'★' * int(s)} ({int(s)})",
141
+ "Count": int(c),
142
+ "Percentage": f"{round(c/total*100,1)}%",
143
+ }
144
+ for s, c in dist.items()
145
+ ]
146
+ return {
147
+ "table": {
148
+ "title": "Rating Distribution",
149
+ "columns": ["Stars", "Count", "Percentage"],
150
+ "rows": rows,
151
+ },
152
+ "summary": f"{len(df)} reviews: avg {round(df['score'].mean(),2)}/5",
153
+ }
154
+
155
+
156
+ def _tool_app_comparison(df: pd.DataFrame) -> dict:
157
+ """Per-app avg rating + negative % table."""
158
+ if "appId" not in df.columns and "appTitle" not in df.columns:
159
+ return {"error": "No app column in data"}
160
+
161
+ app_col = "appTitle" if "appTitle" in df.columns else "appId"
162
+ rows = []
163
+ for app_name, grp in df.groupby(app_col):
164
+ sc = pd.to_numeric(grp["score"], errors="coerce")
165
+ rows.append({
166
+ "App": str(app_name),
167
+ "Reviews": len(grp),
168
+ "Avg Rating": f"{round(float(sc.mean()),2)} ★",
169
+ "% Negative": f"{round(float((sc <= 2).mean()*100),1)}%",
170
+ "% Positive": f"{round(float((sc >= 4).mean()*100),1)}%",
171
+ })
172
+ rows.sort(key=lambda x: x["Avg Rating"])
173
+ return {
174
+ "table": {
175
+ "title": "App Comparison",
176
+ "columns": ["App", "Reviews", "Avg Rating", "% Negative", "% Positive"],
177
+ "rows": rows,
178
+ },
179
+ "summary": f"Compared {len(rows)} apps",
180
+ }
181
+
182
+
183
+ def _tool_top_reviews(df: pd.DataFrame, min_stars: int = 1,
184
+ max_stars: int = 2, n: int = 5,
185
+ app_filter: str = "") -> dict:
186
+ """Filtered review list as table."""
187
+ sc = pd.to_numeric(df["score"], errors="coerce")
188
+ mask = (sc >= min_stars) & (sc <= max_stars)
189
+ if app_filter:
190
+ app_col = "appTitle" if "appTitle" in df.columns else "appId"
191
+ mask &= df[app_col].astype(str).str.lower().str.contains(
192
+ re.escape(app_filter.lower()), na=False)
193
+
194
+ subset = df[mask].head(n)
195
+ tc = "content" if "content" in df.columns else df.columns[0]
196
+ app_col = "appTitle" if "appTitle" in df.columns else ("appId" if "appId" in df.columns else None)
197
+
198
+ rows = []
199
+ for _, r in subset.iterrows():
200
+ row = {
201
+ "User": str(r.get("userName", ""))[:20],
202
+ "Stars": "★" * int(r.get("score", 0)),
203
+ "Review": str(r.get(tc, ""))[:120],
204
+ }
205
+ if app_col:
206
+ row["App"] = str(r.get(app_col, ""))
207
+ if "thumbsUpCount" in df.columns:
208
+ row["Helpful"] = int(r.get("thumbsUpCount", 0))
209
+ rows.append(row)
210
+
211
+ label = f"{min_stars}–{max_stars} star"
212
+ cols = list(rows[0].keys()) if rows else []
213
+ return {
214
+ "table": {
215
+ "title": f"Top {label} Reviews" + (f" — {app_filter}" if app_filter else ""),
216
+ "columns": cols,
217
+ "rows": rows,
218
+ },
219
+ "summary": f"Showing {len(rows)} of {int(mask.sum())} matching reviews",
220
+ }
221
+
222
+
223
+ def _tool_top_helpful(df: pd.DataFrame, n: int = 5) -> dict:
224
+ """Most helpful reviews."""
225
+ if "thumbsUpCount" not in df.columns:
226
+ return {"error": "No helpful votes column"}
227
+ df2 = df.copy()
228
+ df2["__h"] = pd.to_numeric(df2["thumbsUpCount"], errors="coerce").fillna(0)
229
+ subset = df2.nlargest(n, "__h")
230
+ tc = "content" if "content" in df.columns else df.columns[0]
231
+ app_col = "appTitle" if "appTitle" in df.columns else None
232
+
233
+ rows = []
234
+ for _, r in subset.iterrows():
235
+ row = {
236
+ "Stars": "★" * int(r.get("score", 0)),
237
+ "Helpful": int(r.get("thumbsUpCount", 0)),
238
+ "Review": str(r.get(tc, ""))[:120],
239
+ }
240
+ if app_col:
241
+ row["App"] = str(r.get(app_col, ""))
242
+ rows.append(row)
243
+ return {
244
+ "table": {
245
+ "title": "Most Helpful Reviews",
246
+ "columns": list(rows[0].keys()) if rows else [],
247
+ "rows": rows,
248
+ },
249
+ "summary": f"Top {len(rows)} most helpful reviews",
250
+ }
251
+
252
+
253
+ def _tool_keyword_search(df: pd.DataFrame, keyword: str, n: int = 8) -> dict:
254
+ """Search review text for keyword."""
255
+ tc = "content" if "content" in df.columns else df.columns[0]
256
+ mask = df[tc].astype(str).str.lower().str.contains(
257
+ re.escape(keyword.lower()), na=False)
258
+ subset = df[mask].head(n)
259
+ app_col = "appTitle" if "appTitle" in df.columns else None
260
+
261
+ rows = []
262
+ for _, r in subset.iterrows():
263
+ row = {
264
+ "Stars": "★" * int(r.get("score", 0)),
265
+ "Review": str(r.get(tc, ""))[:150],
266
+ }
267
+ if app_col:
268
+ row["App"] = str(r.get(app_col, ""))
269
+ rows.append(row)
270
+ return {
271
+ "table": {
272
+ "title": f'Reviews mentioning "{keyword}"',
273
+ "columns": list(rows[0].keys()) if rows else [],
274
+ "rows": rows,
275
+ },
276
+ "summary": f"Found {int(mask.sum())} reviews mentioning '{keyword}'",
277
+ }
278
+
279
+
280
+ # ═══════════════════════════════════════════════════════════════════════════
281
+ # INTENT CLASSIFIER (enum-strict, multi-class)
282
+ # ═══════════════════════════════════════════════════════════════════════════
283
+
284
+ INTENT_SYSTEM = """You are an intent classifier for a game-review chat assistant.
285
+ Classify the user message into EXACTLY ONE of these intents:
286
+
287
+ TABLE — user wants data in tabular / structured / list format
288
+ COMPARISON — comparing apps / games against each other
289
+ KEYWORD — wants to search for a specific word/phrase in reviews
290
+ HELPFUL — wants the most helpful / upvoted reviews
291
+ ANALYSIS — deep insight, summary, cluster analysis, sentiment, recommendations
292
+ FILTER — filtering the visible table (show only X stars, only app Y)
293
+ GREETING — hi, hello, thanks, small talk
294
+ GENERAL — questions about features, how to use the tool, unrelated
295
+
296
+ Return ONLY one word from the list above. No explanation."""
297
+
298
+
299
+ def classify_intent(message: str, llm) -> str:
300
+ from langchain_core.messages import HumanMessage, SystemMessage
301
+ try:
302
+ resp = llm.invoke([
303
+ SystemMessage(content=INTENT_SYSTEM),
304
+ HumanMessage(content=f'Message: "{message}"'),
305
+ ])
306
+ raw = getattr(resp, "content", str(resp)).strip().upper().split()[0]
307
+ valid = {"TABLE","COMPARISON","KEYWORD","HELPFUL","ANALYSIS","FILTER","GREETING","GENERAL"}
308
+ return raw if raw in valid else "ANALYSIS"
309
+ except Exception:
310
+ return "ANALYSIS"
311
+
312
+
313
+ # ═════════════════════════════════���═════════════════════════════════════════
314
+ # PARAMETER EXTRACTOR (LLM extracts structured params from natural language)
315
+ # ═══════════════════════════════════════════════════════════════════════════
316
+
317
+ def extract_params(message: str, intent: str, llm, apps: list[str]) -> dict:
318
+ """Extract structured parameters from a message given its intent."""
319
+ app_list_str = ", ".join(apps[:10]) if apps else "none"
320
+
321
+ system = f"""Extract parameters from the user message for intent={intent}.
322
+ Known app names in dataset: [{app_list_str}]
323
+
324
+ Return ONLY valid JSON (no markdown):
325
+ {{
326
+ "min_stars": 1-5 or null,
327
+ "max_stars": 1-5 or null,
328
+ "n": integer count or 5,
329
+ "app_filter": "exact app name or title from known list, or empty string",
330
+ "keyword": "search term or empty string",
331
+ "metric": "avg_rating|pct_negative|pct_positive|count or empty"
332
+ }}"""
333
+
334
+ from langchain_core.messages import HumanMessage, SystemMessage
335
+ try:
336
+ resp = llm.invoke([
337
+ SystemMessage(content=system),
338
+ HumanMessage(content=message),
339
+ ])
340
+ raw = getattr(resp, "content", str(resp)).strip()
341
+ raw = re.sub(r"^```(?:json)?", "", raw).strip().rstrip("```")
342
+ return json.loads(raw)
343
+ except Exception:
344
+ return {"min_stars": None, "max_stars": None, "n": 5,
345
+ "app_filter": "", "keyword": "", "metric": ""}
346
+
347
+
348
+ # ═══════════════════════════════════════════════════════════════════════════
349
+ # RESPONSE FORMATTER (converts tool output + agent report → rich reply)
350
+ # ═══════════════════════════════════════════════════════════════════════════
351
+
352
+ def _format_agent_report(report: dict) -> str:
353
+ """Convert agent report dict into a well-structured markdown-like text reply."""
354
+ parts = []
355
+
356
+ if report.get("direct_answer"):
357
+ parts.append(report["direct_answer"])
358
+
359
+ problems = report.get("top_problems", [])
360
+ if problems:
361
+ parts.append("\n**Top Issues:**")
362
+ for i, p in enumerate(problems[:4], 1):
363
+ sev = p.get("severity","").upper()
364
+ issue = p.get("issue","")
365
+ desc = p.get("description","")
366
+ ev = p.get("evidence","")
367
+ parts.append(f"{i}. **{issue}** [{sev}] — {desc}" + (f' _"{ev}"_' if ev else ""))
368
+
369
+ strengths = report.get("key_strengths", [])
370
+ if strengths:
371
+ parts.append("\n**What Users Love:**")
372
+ for s in strengths[:3]:
373
+ parts.append(f"• **{s.get('strength','')}** — {s.get('description','')}")
374
+
375
+ recs = report.get("recommendations", [])
376
+ if recs:
377
+ parts.append("\n**Recommendations:**")
378
+ for i, r in enumerate(recs[:3], 1):
379
+ parts.append(f"{i}. [{r.get('priority','').upper()}] {r.get('action','')} — {r.get('rationale','')}")
380
+
381
+ return "\n".join(parts) if parts else report.get("executive_summary", "Analysis complete.")
382
+
383
+
384
+ def _build_agent_table(report: dict, app_breakdown: list) -> dict | None:
385
+ """If agent ran app_comparison tool, surface it as a table."""
386
+ if not app_breakdown:
387
+ return None
388
+ rows = [
389
+ {
390
+ "App": a.get("app",""),
391
+ "Reviews": a.get("count",""),
392
+ "Avg Rating": f"{a.get('avg_rating','?')} ★",
393
+ "% Negative": f"{a.get('pct_negative','?')}%",
394
+ "% Positive": f"{a.get('pct_positive','?')}%",
395
+ }
396
+ for a in app_breakdown
397
+ ]
398
+ return {
399
+ "title": "App Breakdown",
400
+ "columns": ["App","Reviews","Avg Rating","% Negative","% Positive"],
401
+ "rows": rows,
402
+ }
403
+
404
+
405
+ # ═══════════════════════════════════════════════════════════════════════════
406
+ # /chat ENDPOINT — the core of PlayPulse Intelligence
407
+ # ═══════════════════════════════════════════════════════════════════════════
408
+
409
+ @app.route('/chat', methods=['POST'])
410
+ def chat():
411
+ try:
412
+ data = request.json or {}
413
+ user_message = data.get('message', '').strip()
414
+ current_reviews = data.get('reviews', [])
415
+ session_id = data.get('session_id') or request.remote_addr or "default"
416
+
417
+ if not user_message:
418
+ return jsonify({"error": "No message provided"}), 400
419
+
420
+ llm = build_llm()
421
+ if not llm:
422
+ return jsonify({"reply": "AI service unavailable — no API key configured.", "type": "error"})
423
+
424
+ # ── Conversation memory ────────────────────────────────────────────
425
+ memory = _CONV_MEMORY[session_id]
426
+ memory.append({"role": "user", "content": user_message})
427
+
428
+ # ── Build context from reviews ─────────────────────────────────────
429
+ df = pd.DataFrame(current_reviews) if current_reviews else pd.DataFrame()
430
+ has_data = not df.empty
431
+
432
+ # Detected app names for parameter extraction
433
+ apps: list[str] = []
434
+ if has_data:
435
+ for col in ["appTitle", "appId"]:
436
+ if col in df.columns:
437
+ apps = df[col].dropna().astype(str).unique().tolist()
438
+ break
439
+
440
+ # ── Classify intent ────────────────────────────────────────────────
441
+ intent = classify_intent(user_message, llm)
442
+ print(f"[ChatRouter] Intent: {intent} | has_data: {has_data} | apps: {apps[:3]}")
443
+
444
+ # ── Handle GREETING / GENERAL ──────────────────────────────────────
445
+ if intent in ("GREETING", "GENERAL"):
446
+ from langchain_core.messages import HumanMessage, SystemMessage
447
+ history_msgs = []
448
+ for turn in list(memory)[-MAX_HISTORY_FOR_LLM:]:
449
+ if turn["role"] == "user":
450
+ history_msgs.append(HumanMessage(content=turn["content"]))
451
+ else:
452
+ from langchain_core.messages import AIMessage
453
+ history_msgs.append(AIMessage(content=turn["content"]))
454
+
455
+ sys_msg = SystemMessage(content=(
456
+ "You are PlayPulse Intelligence, a friendly AI assistant for analyzing "
457
+ "Google Play Store reviews. Be helpful, concise, and conversational. "
458
+ "If the user greets you, greet back briefly. "
459
+ "If they ask what you can do, explain you can analyze reviews, compare apps, "
460
+ "find issues, show ratings, and answer questions about the scraped data."
461
+ ))
462
+ resp = llm.invoke([sys_msg] + history_msgs)
463
+ reply = getattr(resp, "content", str(resp)).strip()
464
+ memory.append({"role": "assistant", "content": reply})
465
+ return jsonify({"reply": reply, "type": "general"})
466
+
467
+ # ── No data loaded — ask user to scrape first ─────────────────────
468
+ if not has_data and intent not in ("GREETING","GENERAL"):
469
+ reply = ("No reviews loaded yet. Please scrape an app first using the search bar, "
470
+ "then I can analyze the data for you! 🎮")
471
+ memory.append({"role": "assistant", "content": reply})
472
+ return jsonify({"reply": reply, "type": "general"})
473
+
474
+ # ── FILTER intent ─────────────────────────────────────────────────
475
+ if intent == "FILTER":
476
+ params = extract_params(user_message, intent, llm, apps)
477
+ filter_payload: dict = {}
478
+ if params.get("min_stars"):
479
+ stars = list(range(
480
+ int(params.get("min_stars",1)),
481
+ int(params.get("max_stars",params.get("min_stars",1)))+1
482
+ ))
483
+ filter_payload["stars"] = stars
484
+ if params.get("app_filter"):
485
+ filter_payload["app"] = params["app_filter"]
486
+ if params.get("keyword"):
487
+ filter_payload["query"] = params["keyword"]
488
+
489
+ # Also show a summary table via TABLE tool
490
+ result = _tool_top_reviews(
491
+ df,
492
+ min_stars=int(params.get("min_stars") or 1),
493
+ max_stars=int(params.get("max_stars") or 5),
494
+ n=int(params.get("n") or 8),
495
+ app_filter=params.get("app_filter",""),
496
+ )
497
+ reply = result.get("summary","Filters applied.")
498
+ table = result.get("table")
499
+ memory.append({"role": "assistant", "content": reply})
500
+ return jsonify({
501
+ "reply": reply,
502
+ "filters": filter_payload,
503
+ "table": table,
504
+ "type": "filter",
505
+ })
506
+
507
+ # ── COMPARISON intent ─────────────────────────────────────────────
508
+ if intent == "COMPARISON":
509
+ result = _tool_app_comparison(df)
510
+ if "error" in result:
511
+ reply = result["error"]
512
+ memory.append({"role": "assistant", "content": reply})
513
+ return jsonify({"reply": reply, "type": "general"})
514
+
515
+ # Also ask LLM to narrate
516
+ narration_prompt = (
517
+ f"Here is a comparison table of apps by rating:\n"
518
+ f"{json.dumps(result['table']['rows'], indent=2)}\n\n"
519
+ f"User asked: '{user_message}'\n"
520
+ f"Write a 2-3 sentence natural language summary highlighting "
521
+ f"the worst and best performing apps."
522
+ )
523
+ from langchain_core.messages import HumanMessage
524
+ narr_resp = llm.invoke([HumanMessage(content=narration_prompt)])
525
+ narration = getattr(narr_resp, "content", str(narr_resp)).strip()
526
+
527
+ memory.append({"role": "assistant", "content": narration})
528
+ return jsonify({
529
+ "reply": narration,
530
+ "table": result["table"],
531
+ "type": "comparison",
532
+ })
533
+
534
+ # ── TABLE intent ──────────────────────────────────────────────────
535
+ if intent == "TABLE":
536
+ # Check what the PREVIOUS assistant message was about
537
+ # so "get me this in tabular format" works correctly
538
+ prev_context = ""
539
+ history = list(memory)
540
+ for turn in reversed(history[:-1]): # skip current user msg
541
+ if turn["role"] == "assistant":
542
+ prev_context = turn["content"]
543
+ break
544
+
545
+ # If previous answer was about app comparison / ratings → show comparison table
546
+ comp_keywords = ["rating","low rating","negative","ranked","comparison","games"]
547
+ if any(k in prev_context.lower() for k in comp_keywords) or "tabular" in user_message.lower():
548
+ result = _tool_app_comparison(df)
549
+ if "table" in result:
550
+ reply = f"Here's the comparison table. {result['summary']}"
551
+ memory.append({"role": "assistant", "content": reply})
552
+ return jsonify({
553
+ "reply": reply,
554
+ "table": result["table"],
555
+ "type": "table",
556
+ })
557
+
558
+ # Otherwise extract params and show filtered reviews table
559
+ params = extract_params(user_message, "TABLE", llm, apps)
560
+ result = _tool_top_reviews(
561
+ df,
562
+ min_stars=int(params.get("min_stars") or 1),
563
+ max_stars=int(params.get("max_stars") or 5),
564
+ n=int(params.get("n") or 10),
565
+ app_filter=params.get("app_filter",""),
566
+ )
567
+ reply = result.get("summary","")
568
+ memory.append({"role": "assistant", "content": reply})
569
+ return jsonify({
570
+ "reply": reply,
571
+ "table": result.get("table"),
572
+ "type": "table",
573
+ })
574
+
575
+ # ── KEYWORD intent ────────────────────────────────────────────────
576
+ if intent == "KEYWORD":
577
+ params = extract_params(user_message, intent, llm, apps)
578
+ kw = params.get("keyword","")
579
+ if not kw:
580
+ # Ask LLM to extract keyword from message
581
+ from langchain_core.messages import HumanMessage
582
+ kw_resp = llm.invoke([HumanMessage(content=(
583
+ f'Extract the search keyword or phrase from: "{user_message}". '
584
+ f'Return ONLY the keyword, nothing else.'
585
+ ))])
586
+ kw = getattr(kw_resp, "content", str(kw_resp)).strip().strip('"')
587
+
588
+ result = _tool_keyword_search(df, kw, n=10)
589
+ reply = result.get("summary","")
590
+ memory.append({"role": "assistant", "content": reply})
591
+ return jsonify({
592
+ "reply": reply,
593
+ "table": result.get("table"),
594
+ "type": "keyword",
595
+ })
596
+
597
+ # ── HELPFUL intent ────────────────────────────────────────────────
598
+ if intent == "HELPFUL":
599
+ params = extract_params(user_message, intent, llm, apps)
600
+ result = _tool_top_helpful(df, n=int(params.get("n") or 5))
601
+ if "error" in result:
602
+ reply = result["error"]
603
+ else:
604
+ reply = result.get("summary","")
605
+ memory.append({"role": "assistant", "content": reply})
606
+ return jsonify({
607
+ "reply": reply,
608
+ "table": result.get("table"),
609
+ "type": "helpful",
610
+ })
611
+
612
+ # ── ANALYSIS intent (deep — calls LangGraph agent) ────────────────
613
+ # Also used as fallback for everything not caught above
614
+ # Build conversation context string for agent
615
+ history_context = "\n".join(
616
+ f"{'User' if t['role']=='user' else 'Assistant'}: {t['content']}"
617
+ for t in list(memory)[-MAX_HISTORY_FOR_LLM:]
618
+ )
619
+ enriched_query = (
620
+ f"Conversation so far:\n{history_context}\n\n"
621
+ f"User's current question: {user_message}"
622
+ ) if len(memory) > 2 else user_message
623
+
624
+ # Run the full LangGraph agent
625
+ agent_state = run_agent(enriched_query, df=df if has_data else None)
626
+ report = agent_state.get("report", {})
627
+ breakdown = agent_state.get("app_breakdown", [])
628
+
629
+ # Format the reply text
630
+ reply = _format_agent_report(report)
631
+ if not reply.strip():
632
+ reply = report.get("executive_summary","I've completed the analysis.")
633
+
634
+ # Build optional table from app breakdown
635
+ table = _build_agent_table(report, breakdown)
636
+
637
+ memory.append({"role": "assistant", "content": reply})
638
+ return jsonify({
639
+ "reply": reply,
640
+ "table": table,
641
+ "agent_data": {
642
+ "top_problems": report.get("top_problems",[]),
643
+ "key_strengths": report.get("key_strengths",[]),
644
+ "recommendations": report.get("recommendations",[]),
645
+ "clusters": agent_state.get("clusters",[]),
646
+ "sentiment": agent_state.get("sentiment",{}),
647
+ "stats": agent_state.get("stats",{}),
648
+ },
649
+ "type": "analysis",
650
+ })
651
+
652
+ except Exception as e:
653
+ import traceback
654
+ print(f"[Chat ERROR] {e}\n{traceback.format_exc()}")
655
+ return jsonify({"error": str(e)}), 500
656
+
657
+
658
+ # ═══════════════════════════════════════════════════════════════════════════
659
+ # SCRAPE ROUTES (unchanged from v1)
660
+ # ═══════════════════════════════════════════════════════════════════════════
661
 
662
  @app.route('/scrape', methods=['POST'])
663
  def scrape():
664
  try:
665
+ data = request.json
666
+ identifier = data.get('identifier', '').strip()
667
+ count_type = data.get('review_count_type', 'fixed')
668
+ count = 100000 if count_type == 'all' else data.get('review_count', 150)
669
+
670
  app_id = extract_app_id(identifier)
671
  if not app_id:
 
672
  results = search(identifier, lang="en", country="us", n_hits=1)
673
  if results and results[0].get('appId'):
674
  app_id = results[0]['appId']
675
  else:
 
676
  pids = scrape_store_ids(identifier, n_hits=1)
677
+ if pids:
678
+ app_id = pids[0]
679
+ else:
680
+ return jsonify({"error": f"App '{identifier}' not found"}), 404
681
 
682
  info, all_reviews = fetch_app_reviews(
683
+ app_id, count, data.get('sort_order'), data.get('star_ratings'))
 
684
 
685
  return jsonify({
686
  "app_info": {
687
+ "title": info['title'],
688
+ "icon": info['icon'],
689
+ "score": info['score'],
690
  "reviews": info['reviews'],
691
+ "appId": app_id,
692
  },
693
  "reviews": all_reviews,
694
  })
 
695
  except Exception as e:
696
  return jsonify({"error": str(e)}), 500
697
 
 
699
  @app.route('/find-apps', methods=['POST'])
700
  def find_apps():
701
  try:
702
+ data = request.json
703
+ query = data.get('query', '').strip()
704
  app_count = int(data.get('app_count', 10))
705
+ app_ids = scrape_store_ids(query, n_hits=app_count)
 
 
 
706
  if not app_ids:
707
+ hits = search(query, lang="en", country="us", n_hits=app_count)
 
708
  app_ids = [h['appId'] for h in hits if h.get('appId')]
709
+
710
  results = []
711
  for aid in app_ids:
712
  try:
713
  info = app_info(aid, lang='en', country='us')
714
  results.append({
715
+ "appId": aid,
716
+ "title": info['title'],
717
+ "icon": info['icon'],
718
+ "score": info['score'],
719
+ "developer": info.get('developer','Unknown'),
720
+ "installs": info.get('installs','0+'),
721
  })
722
+ except Exception:
723
+ continue
724
+
725
  return jsonify({"results": results})
726
  except Exception as e:
727
  return jsonify({"error": str(e)}), 500
728
 
729
+
730
  @app.route('/scrape-batch', methods=['POST'])
731
  def scrape_batch():
732
  try:
733
+ data = request.json
734
+ app_ids = data.get('app_ids', [])
735
+ count_type = data.get('review_count_type', 'fixed')
736
  reviews_per_app = 100000 if count_type == 'all' else int(data.get('reviews_per_app', 100))
737
+
738
  if not app_ids:
739
  return jsonify({"error": "No app IDs provided"}), 400
740
+
741
+ batch_results: list[dict] = []
742
+ all_combined: list[dict] = []
743
+
744
  for app_id in app_ids:
745
  try:
746
  info, app_reviews = fetch_app_reviews(
747
+ app_id, reviews_per_app, data.get('sort_order'), data.get('star_ratings'))
 
748
  batch_results.append({
749
  "title": info['title'],
750
+ "icon": info['icon'],
751
  "score": info['score'],
752
+ "appId": app_id,
753
  })
754
+ all_combined.extend(app_reviews)
755
+ except Exception:
756
  continue
757
+
758
+ return jsonify({"apps": batch_results, "reviews": all_combined})
 
 
 
759
  except Exception as e:
760
  return jsonify({"error": str(e)}), 500
761
 
762
 
 
763
  @app.route("/search-suggestions", methods=["POST"])
764
  def search_suggestions():
 
765
  try:
766
+ query = (request.json or {}).get("query","").strip()
767
  if not query or len(query) < 2:
768
  return jsonify({"results": []})
769
 
770
  hits = search(query, lang="en", country="us", n_hits=6)
771
  results = []
772
  for h in hits:
773
+ aid = h.get("appId","")
774
  if not aid or aid == "None" or "." not in aid:
775
  continue
776
  results.append({
777
  "appId": aid,
778
  "storeUrl": f"https://play.google.com/store/apps/details?id={aid}",
779
+ "title": h.get("title",""),
780
+ "icon": h.get("icon",""),
781
  "score": round(h.get("score") or 0, 1),
782
+ "developer": h.get("developer",""),
783
+ "installs": h.get("installs",""),
784
  })
 
785
  return jsonify({"results": results[:5]})
786
  except Exception as e:
787
  return jsonify({"error": str(e)}), 500
788
 
789
 
790
+ # ═════════════════════════════════════════════════════════════════════════���═
791
+ # CLEAR CHAT MEMORY (optional endpoint for "New Chat" button)
792
+ # ═══════════════════════════════════════════════════════════════════════════
793
+
794
+ @app.route('/chat/clear', methods=['POST'])
795
+ def clear_chat():
796
+ session_id = (request.json or {}).get('session_id') or request.remote_addr or "default"
797
+ _CONV_MEMORY[session_id].clear()
798
+ return jsonify({"ok": True})
799
+
800
+
801
+ # ═══════════════════════════════════════════════════════════════════════════
802
+ # PAGE ROUTES
803
+ # ═══════════════════════════════════════════════════════════════════════════
804
+
805
  @app.route('/scraper')
806
  def scraper():
807
  return render_template('index.html')
808
 
 
809
  @app.route('/batch')
810
  def batch():
811
  return render_template('batch.html')
812
 
 
813
  @app.route('/')
814
  def landing():
815
  return render_template('landing.html')
816
 
817
+
818
  if __name__ == "__main__":
819
  app.run(host="0.0.0.0", debug=True, port=7860)
templates/batch.html CHANGED
@@ -6,90 +6,78 @@
6
  <title>Batch Intelligence | PlayPulse</title>
7
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
8
  <style>
9
- :root {
10
- --bg: #0b0e14;
11
- --surface: #151921;
12
- --surface2: #1c2333;
13
- --border: #232a35;
14
- --accent: #3b82f6;
15
- --accent-dim: rgba(59,130,246,0.12);
16
- --green: #22c55e;
17
- --green-dim: rgba(34,197,94,0.12);
18
- --amber: #f59e0b;
19
- --text: #f1f5f9;
20
- --muted: #64748b;
21
- --muted2: #94a3b8;
22
- }
23
- * { box-sizing: border-box; margin: 0; padding: 0; }
24
-
25
- /* Modern Scrollbar */
26
- ::-webkit-scrollbar { width: 6px; height: 6px; }
27
- ::-webkit-scrollbar-track { background: transparent; }
28
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
29
- ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
30
- * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
31
-
32
- body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
33
-
34
- .btn-sm { background: var(--surface2); border: 1px solid var(--border); color: white; padding: 4px 10px; border-radius: 6px; font-size: 10px; cursor: pointer; transition: 0.2s; }
35
- .btn-sm:hover { border-color: var(--accent); }
36
-
37
- .header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
38
- .main { flex: 1; display: flex; overflow: hidden; }
39
- .sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 15px; display: flex; flex-direction: column; gap: 15px; overflow-y: auto; }
40
- .content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
41
-
42
- .mode-toggle { display: grid; grid-template-columns: 1fr 1fr; background: var(--bg); padding: 4px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 5px; }
43
- .mode-btn { padding: 8px; border-radius: 7px; text-align: center; cursor: pointer; font-size: 11px; font-weight: 700; color: var(--muted); transition: 0.2s; }
44
- .mode-btn.active { background: var(--surface2); color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
45
-
46
- .logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; text-decoration: none; }
47
- .input-group { display: flex; flex-direction: column; gap: 6px; }
48
- .label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; }
49
- input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 10px; border-radius: 8px; font-size: 12px; outline: none; width: 100%; }
50
- input:focus { border-color: var(--accent); }
51
-
52
- .btn-main { background: var(--accent); color: white; border: none; padding: 14px; border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; width: 100%; border-bottom: 3px solid rgba(0,0,0,0.2); }
53
- .btn-main:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
54
- .btn-main:disabled { opacity: 0.5; cursor: not-allowed; }
55
-
56
- .scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
57
-
58
- /* Results Header */
59
- .batch-summary { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
60
- .apps-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
61
- .app-mini-card { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 10px; display: flex; align-items: center; gap: 10px; }
62
- .app-mini-card img { width: 32px; height: 32px; border-radius: 6px; }
63
- .app-mini-info { flex: 1; min-width: 0; }
64
- .app-mini-title { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
65
- .app-mini-score { font-size: 10px; color: var(--amber); }
66
-
67
- /* Table Styles */
68
- .table-container { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
69
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
70
- th { text-align: left; background: var(--surface2); padding: 12px 16px; color: var(--muted2); font-weight: 700; font-size: 11px; text-transform: uppercase; border-bottom: 1px solid var(--border); }
71
- td { padding: 14px 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
72
- tr:last-child td { border-bottom: none; }
73
- tr:hover td { background: rgba(255,255,255,0.02); }
74
-
75
- .app-tag { display: inline-flex; align-items: center; gap: 6px; background: var(--accent-dim); color: var(--accent); padding: 4px 8px; border-radius: 6px; font-weight: 700; font-size: 10px; margin-bottom: 6px; border: 1px solid rgba(59,130,246,0.2); }
76
- .score-stars { color: var(--amber); white-space: nowrap; }
77
- .review-content { color: #cbd5e1; line-height: 1.5; max-width: 400px; }
78
-
79
- /* Overlays */
80
- .star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
81
- .star-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; transition: border-color 0.15s; user-select: none; }
82
- .star-row:hover { border-color: var(--accent); }
83
- .star-row input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; padding: 0; border: none; background: transparent; flex-shrink: 0; }
84
- .star-label { display: flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; flex: 1; }
85
- .stars-on { color: var(--amber); letter-spacing: -1px; }
86
- .stars-off { color: var(--border); letter-spacing: -1px; }
87
-
88
- .loader-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
89
- .spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
90
- @keyframes spin { to { transform: rotate(360deg); } }
91
-
92
- .hidden { display: none !important; }
93
  </style>
94
  </head>
95
  <body>
@@ -99,13 +87,13 @@
99
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
100
  BATCH INTEL
101
  </a>
102
- <nav style="margin-left: 30px; display: flex; gap: 20px;">
103
- <a href="/" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
104
- <a href="/scraper" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Single Explorer</a>
105
- <a href="/batch" style="color: var(--text); text-decoration: none; font-size: 13px; font-weight: 700; border-bottom: 2px solid var(--accent); padding-bottom: 4px;">Batch Intelligence</a>
106
  </nav>
107
  <div style="flex:1"></div>
108
- <button class="btn-icon" onclick="downloadCSV()" style="background:var(--surface2); border:1px solid var(--border); color:white; padding:8px 16px; border-radius:8px; cursor:pointer; font-size:12px; font-weight:700;">Export Combined CSV</button>
109
  </div>
110
 
111
  <div class="main">
@@ -114,55 +102,37 @@
114
  <div class="label">Step 1: Discover Apps</div>
115
  <div style="display:flex;gap:8px;">
116
  <input type="text" id="query" placeholder="e.g. Multiplayer Games..." value="Multiplayer Games" style="flex:1">
117
- <button onclick="findApps()" id="btnFind" style="background:var(--accent); border:none; color:white; padding:0 15px; border-radius:8px; cursor:pointer; font-weight:700;">Find</button>
118
  </div>
119
  </div>
120
-
121
  <div class="input-group">
122
  <div class="label">Discovery Limit</div>
123
  <input type="number" id="app_count" value="10" min="1" max="50">
124
- <div style="font-size:10px; color:var(--muted); margin-top:4px;">How many apps to search for initially.</div>
125
  </div>
126
-
127
- <div id="selectionArea" class="hidden" style="background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:10px; display:flex; flex-direction:column; gap:8px;">
128
- <div class="label" style="display:flex; justify-content:space-between; align-items:center;">
129
- <span>Select Apps</span>
130
- <span id="selectedCount" style="color:var(--accent); font-size:9px;">0 selected</span>
131
- </div>
132
- <div id="appList" style="max-height:160px; overflow-y:auto; overflow-x:hidden; display:flex; flex-direction:column; gap:4px; padding-right:4px;">
133
- <!-- Compact apps list -->
134
- </div>
135
- <div style="display:flex; gap:5px;">
136
- <button onclick="toggleAllApps(true)" class="btn-sm" style="flex:1">All</button>
137
- <button onclick="toggleAllApps(false)" class="btn-sm" style="flex:1">None</button>
138
- </div>
139
  </div>
140
-
141
  <div class="input-group">
142
  <div class="label">Step 2: Scrape Settings</div>
143
- <div class="label" style="font-size:10px; margin-top:10px;">Reviews Per App</div>
144
  <div class="mode-toggle">
145
  <div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div>
146
  <div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div>
147
  </div>
148
  <input type="number" id="reviews_per_app" value="50" min="10" step="10">
149
  </div>
150
-
151
  <div class="input-group">
152
  <div class="label">Sort Method</div>
153
- <select id="sort">
154
- <option value="MOST_RELEVANT">Most Relevant</option>
155
- <option value="NEWEST">Newest</option>
156
- <option value="RATING">Top Ratings</option>
157
- </select>
158
  </div>
159
-
160
  <div class="input-group">
161
- <div class="label">
162
  <span>Star Rating Filter</span>
163
- <div style="display:flex;gap:5px">
164
- <button class="quick-btn" style="font-size:9px; padding:2px 5px; cursor:pointer; background:var(--surface2); border:1px solid var(--border); color:white; border-radius:4px;" onclick="selectAllStars(true)">All</button>
165
- <button class="quick-btn" style="font-size:9px; padding:2px 5px; cursor:pointer; background:var(--surface2); border:1px solid var(--border); color:white; border-radius:4px;" onclick="selectAllStars(false)">None</button>
166
  </div>
167
  </div>
168
  <div class="star-filter-grid">
@@ -173,16 +143,11 @@
173
  <label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">★</span><span class="stars-off">★★★★</span></span></label>
174
  </div>
175
  </div>
176
-
177
  <button class="btn-main" id="go" onclick="runBatch()">
178
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
179
  RUN BATCH ANALYSIS
180
  </button>
181
-
182
- <div style="background:var(--bg); padding:15px; border-radius:12px; border:1px solid var(--border); font-size:11px; color:var(--muted); line-height:1.6;">
183
- <strong style="color:var(--text)">About Batch Mode</strong><br>
184
- This will search for apps matching your query, scrape reviews for each, and combine them into a single comparison set.
185
- </div>
186
  </aside>
187
 
188
  <div class="content">
@@ -191,21 +156,24 @@
191
  <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
192
  <p>Run batch analysis to compare app data</p>
193
  </div>
194
-
195
  <div id="results" class="hidden">
196
  <div class="batch-summary">
197
  <div class="label">Comparing These Apps:</div>
198
  <div class="apps-grid" id="appsGrid"></div>
199
  </div>
200
-
 
 
 
201
  <div class="table-container">
202
  <table id="reviewsTable">
203
  <thead>
204
  <tr>
205
- <th style="width:160px">Application</th>
206
- <th style="width:80px">Score</th>
207
- <th>Review Snippet</th>
208
- <th style="width:120px">Date</th>
 
209
  </tr>
210
  </thead>
211
  <tbody id="reviewsBody"></tbody>
@@ -213,7 +181,6 @@
213
  </div>
214
  </div>
215
  </div>
216
-
217
  <div id="loader" class="loader-overlay hidden">
218
  <div class="spinner"></div>
219
  <p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p>
@@ -221,176 +188,196 @@
221
  </div>
222
  </div>
223
 
224
- <script>
225
- let currentData = null;
226
- let currentMode = 'fixed';
227
-
228
- function setMode(m) {
229
- currentMode = m;
230
- document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
231
- document.getElementById('btn-' + m).classList.add('active');
232
- document.getElementById('reviews_per_app').classList.toggle('hidden', m === 'all');
233
- }
234
-
235
- function selectAllStars(check) {
236
- document.querySelectorAll('.star-cb').forEach(cb => cb.checked = check);
237
- }
238
-
239
- let foundApps = [];
240
-
241
- async function findApps() {
242
- const q = document.getElementById('query').value.trim();
243
- if (!q) return;
244
-
245
- const btn = document.getElementById('btnFind');
246
- btn.disabled = true;
247
- btn.innerText = 'Searching...';
248
 
249
- try {
250
- const res = await fetch('/find-apps', {
251
- method: 'POST',
252
- headers: { 'Content-Type': 'application/json' },
253
- body: JSON.stringify({ query: q, app_count: document.getElementById('app_count').value })
254
- });
255
- const data = await res.json();
256
- if (!res.ok) throw new Error(data.error || 'Discovery failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
- foundApps = data.results;
259
- renderAppSelection();
260
- } catch(e) {
261
- alert(e.message);
262
- } finally {
263
- btn.disabled = false;
264
- btn.innerText = 'Find';
265
- }
 
 
 
 
 
 
 
 
266
  }
267
 
268
- function renderAppSelection() {
269
- const list = document.getElementById('appList');
270
  document.getElementById('selectionArea').classList.remove('hidden');
271
-
272
- list.innerHTML = foundApps.map(a => `
273
- <label style="display:flex; align-items:center; gap:8px; padding:6px; background:var(--bg); border-radius:6px; border:1px solid var(--border); cursor:pointer; min-width:0;">
274
- <input type="checkbox" class="app-cb" value="${a.appId}" onchange="updateSelectionCount()" checked style="width:14px; height:14px; margin:0;">
275
- <img src="${a.icon}" style="width:20px; height:20px; border-radius:4px; flex-shrink:0;">
276
- <div style="flex:1; min-width:0;">
277
- <div style="font-size:10px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--text);">${a.title}</div>
278
- <div style="font-size:9px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${a.developer}</div>
279
- </div>
280
- </label>
281
- `).join('');
282
- updateSelectionCount();
283
- }
284
-
285
- function toggleAllApps(check) {
286
- document.querySelectorAll('.app-cb').forEach(cb => cb.checked = check);
287
  updateSelectionCount();
288
  }
289
 
290
- function updateSelectionCount() {
291
- const count = document.querySelectorAll('.app-cb:checked').length;
292
- document.getElementById('selectedCount').innerText = `${count} selected`;
293
- document.getElementById('go').disabled = count === 0;
 
 
 
 
 
 
 
 
 
 
 
294
  }
295
 
296
- async function runBatch() {
297
- const selectedAppIds = [...document.querySelectorAll('.app-cb:checked')].map(cb => cb.value);
298
- if (!selectedAppIds.length) return alert('Select at least one app');
299
-
300
- const stars = [...document.querySelectorAll('.star-cb:checked')].map(cb => parseInt(cb.value));
301
- if (!stars.length) return alert('Select at least one star rating');
302
-
303
- document.getElementById('welcome').classList.add('hidden');
304
- document.getElementById('results').classList.add('hidden');
305
- document.getElementById('loader').classList.remove('hidden');
306
- document.getElementById('go').disabled = true;
307
-
308
- try {
309
- const res = await fetch('/scrape-batch', {
310
- method: 'POST',
311
- headers: { 'Content-Type': 'application/json' },
312
- body: JSON.stringify({
313
- app_ids: selectedAppIds,
314
- review_count_type: currentMode,
315
- reviews_per_app: document.getElementById('reviews_per_app').value,
316
- sort_order: document.getElementById('sort').value,
317
- star_ratings: stars.length === 5 ? 'all' : stars
318
- })
319
- });
320
-
321
- const data = await res.json();
322
- if (!res.ok) throw new Error(data.error || 'Batch scraping failed');
323
-
324
- currentData = data;
325
- render(data);
326
- } catch(e) {
327
- alert(e.message);
328
- } finally {
329
- document.getElementById('loader').classList.add('hidden');
330
- document.getElementById('go').disabled = false;
331
- }
332
- }
333
-
334
- function render(data) {
335
  document.getElementById('results').classList.remove('hidden');
 
 
336
 
337
- // Render apps list
338
- document.getElementById('appsGrid').innerHTML = data.apps.map(a => `
339
- <div class="app-mini-card">
340
- <img src="${a.icon}" alt="">
341
- <div class="app-mini-info">
342
- <div class="app-mini-title">${a.title}</div>
343
- <div class="app-mini-score">${a.score.toFixed(1)} ★</div>
344
- </div>
345
- </div>
346
- `).join('');
347
-
348
- // Render reviews table
349
- document.getElementById('reviewsBody').innerHTML = data.reviews.map(r => {
350
- const app = data.apps.find(a => a.appId === r.appId) || {title: r.appTitle};
351
  return `
352
  <tr>
353
  <td>
354
  <div class="app-tag">${app.title}</div>
355
- <div style="font-size:11px; font-weight:700;">${r.userName}</div>
356
  </td>
357
  <td>
358
- <div class="score-stars">${'★'.repeat(r.score)}</div>
359
  </td>
360
  <td>
361
  <div class="review-content">${r.content}</div>
 
362
  </td>
363
- <td>
364
- <div style="color:var(--muted); font-size:11px;">${new Date(r.at).toLocaleDateString()}</div>
365
- </td>
366
  </tr>
367
  `;
368
  }).join('');
369
  }
370
 
371
- function downloadCSV() {
372
- if (!currentData) return;
373
- const esc = v => `"${String(v||'').replace(/"/g,'""')}"`;
374
- const hdr = ['App Name', 'App ID', 'User', 'Score', 'Date', 'Content', 'Thumbs Up'];
375
-
376
- const rows = currentData.reviews.map(r => [
377
- esc(r.appTitle),
378
- esc(r.appId),
379
- esc(r.userName),
380
- r.score,
381
- esc(r.at.slice(0,10)),
382
- esc(r.content),
383
- r.thumbsUpCount
384
- ].join(','));
 
 
385
 
386
- const blob = new Blob([[hdr.join(','), ...rows].join('\n')], { type: 'text/csv' });
387
- const url = URL.createObjectURL(blob);
388
- const a = document.createElement('a');
389
- a.href = url;
390
- a.download = `batch_comparison_${new Date().getTime()}.csv`;
391
- a.click();
392
  }
393
- </script>
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  </body>
396
- </html>
 
6
  <title>Batch Intelligence | PlayPulse</title>
7
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
8
  <style>
9
+ :root { --bg:#0b0e14; --surface:#151921; --surface2:#1c2333; --border:#232a35; --accent:#3b82f6; --accent-dim:rgba(59,130,246,0.12); --green:#22c55e; --green-dim:rgba(34,197,94,0.12); --amber:#f59e0b; --text:#f1f5f9; --muted:#64748b; --muted2:#94a3b8; }
10
+ * { box-sizing:border-box; margin:0; padding:0; }
11
+ ::-webkit-scrollbar{width:6px;height:6px;} ::-webkit-scrollbar-track{background:transparent;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:10px;} ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.2);} *{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent;}
12
+ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;}
13
+ .btn-sm{background:var(--surface2);border:1px solid var(--border);color:white;padding:4px 10px;border-radius:6px;font-size:10px;cursor:pointer;transition:0.2s;} .btn-sm:hover{border-color:var(--accent);}
14
+ .header{height:60px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:20px;}
15
+ .main{flex:1;display:flex;overflow:hidden;}
16
+ .sidebar{width:300px;background:var(--surface);border-right:1px solid var(--border);padding:15px;display:flex;flex-direction:column;gap:15px;overflow-y:auto;}
17
+ .content{flex:1;background:var(--bg);position:relative;display:flex;flex-direction:column;}
18
+ .mode-toggle{display:grid;grid-template-columns:1fr 1fr;background:var(--bg);padding:4px;border-radius:10px;border:1px solid var(--border);margin-bottom:5px;}
19
+ .mode-btn{padding:8px;border-radius:7px;text-align:center;cursor:pointer;font-size:11px;font-weight:700;color:var(--muted);transition:0.2s;} .mode-btn.active{background:var(--surface2);color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2);}
20
+ .logo{font-weight:800;font-size:18px;color:var(--accent);display:flex;align-items:center;gap:8px;text-decoration:none;}
21
+ .input-group{display:flex;flex-direction:column;gap:6px;}
22
+ .label{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:0.5px;}
23
+ input,select{background:var(--bg);border:1px solid var(--border);color:white;padding:10px;border-radius:8px;font-size:12px;outline:none;width:100%;} input:focus{border-color:var(--accent);}
24
+ .btn-main{background:var(--accent);color:white;border:none;padding:14px;border-radius:10px;font-weight:800;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:0.2s;width:100%;border-bottom:3px solid rgba(0,0,0,0.2);} .btn-main:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(59,130,246,0.3);} .btn-main:disabled{opacity:0.5;cursor:not-allowed;}
25
+ .scroll-view{flex:1;overflow-y:auto;padding:30px;display:flex;flex-direction:column;gap:25px;}
26
+ .batch-summary{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:15px;}
27
+ .apps-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;}
28
+ .app-mini-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px;display:flex;align-items:center;gap:10px;}
29
+ .app-mini-card img{width:32px;height:32px;border-radius:6px;}
30
+ .app-mini-info{flex:1;min-width:0;} .app-mini-title{font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .app-mini-score{font-size:10px;color:var(--amber);}
31
+ .table-container{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;}
32
+ table{width:100%;border-collapse:collapse;font-size:13px;}
33
+ th{text-align:left;background:var(--surface2);padding:12px 16px;color:var(--muted2);font-weight:700;font-size:11px;text-transform:uppercase;border-bottom:1px solid var(--border);}
34
+ td{padding:14px 16px;border-bottom:1px solid var(--border);vertical-align:top;} tr:last-child td{border-bottom:none;} tr:hover td{background:rgba(255,255,255,0.02);}
35
+ .app-tag{display:inline-flex;align-items:center;gap:6px;background:var(--accent-dim);color:var(--accent);padding:4px 8px;border-radius:6px;font-weight:700;font-size:10px;margin-bottom:6px;border:1px solid rgba(59,130,246,0.2);}
36
+ .score-stars{color:var(--amber);white-space:nowrap;}
37
+ .review-content{color:#cbd5e1;line-height:1.5;max-width:500px;word-wrap:break-word;}
38
+ .dev-reply{margin-top:8px;padding:8px 12px;background:rgba(59,130,246,0.05);border-left:2px solid var(--accent);border-radius:0 6px 6px 0;font-size:11px;color:var(--muted2);}
39
+ .dev-reply-label{font-weight:700;color:var(--accent);font-size:9px;text-transform:uppercase;margin-bottom:3px;display:block;}
40
+ .helpful-pill{display:inline-flex;align-items:center;gap:4px;background:var(--surface2);padding:4px 8px;border-radius:12px;font-size:10px;color:var(--muted2);border:1px solid var(--border);}
41
+ .helpful-pill svg{width:10px;height:10px;color:var(--accent);}
42
+ .star-filter-grid{display:flex;flex-direction:column;gap:6px;}
43
+ .star-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;border:1px solid var(--border);background:var(--bg);cursor:pointer;transition:border-color 0.15s;user-select:none;} .star-row:hover{border-color:var(--accent);}
44
+ .star-row input[type="checkbox"]{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;padding:0;border:none;background:transparent;flex-shrink:0;}
45
+ .star-label{display:flex;align-items:center;gap:5px;font-size:13px;font-weight:600;flex:1;} .stars-on{color:var(--amber);letter-spacing:-1px;} .stars-off{color:var(--border);letter-spacing:-1px;}
46
+ .loader-overlay{position:absolute;inset:0;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:15px;z-index:10;}
47
+ .spinner{width:40px;height:40px;border:4px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;}
48
+ @keyframes spin{to{transform:rotate(360deg);}}
49
+ .hidden{display:none!important;}
50
+
51
+ /* ── Chat styles ── */
52
+ #chat-dialer{position:fixed;bottom:24px;right:24px;width:56px;height:56px;background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 32px rgba(59,130,246,0.4);cursor:pointer;z-index:1000;transition:0.3s cubic-bezier(0.175,0.885,0.32,1.275);border:2px solid rgba(255,255,255,0.1);}
53
+ #chat-dialer:hover{transform:scale(1.1) rotate(5deg);box-shadow:0 12px 40px rgba(59,130,246,0.6);}
54
+ #chat-dialer svg{width:24px;height:24px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;}
55
+ #chat-window{position:fixed;bottom:90px;right:24px;width:420px;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:20px;display:flex;flex-direction:column;box-shadow:0 20px 50px rgba(0,0,0,0.5);z-index:1001;overflow:hidden;transform:translateY(20px) scale(0.95);opacity:0;pointer-events:none;transition:0.3s cubic-bezier(0.4,0,0.2,1);backdrop-filter:blur(20px);}
56
+ #chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;}
57
+ .chat-header{padding:14px 18px;background:var(--accent);color:white;display:flex;align-items:center;gap:12px;flex-shrink:0;}
58
+ .chat-header-info{flex:1;} .chat-header-title{font-weight:800;font-size:15px;} .chat-header-status{font-size:10px;opacity:0.8;display:flex;align-items:center;gap:4px;} .status-dot{width:6px;height:6px;background:#22c55e;border-radius:50%;}
59
+ .chat-header-actions{display:flex;gap:8px;align-items:center;}
60
+ .chat-clear-btn{background:rgba(255,255,255,0.15);border:none;color:white;font-size:11px;padding:4px 10px;border-radius:8px;cursor:pointer;transition:0.2s;} .chat-clear-btn:hover{background:rgba(255,255,255,0.25);}
61
+ .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background-image:radial-gradient(var(--border) 1px,transparent 1px);background-size:20px 20px;}
62
+ .msg-row{display:flex;flex-direction:column;gap:4px;} .msg-row.user{align-items:flex-end;} .msg-row.bot{align-items:flex-start;}
63
+ .message{max-width:88%;padding:11px 15px;border-radius:16px;font-size:13px;line-height:1.6;}
64
+ .message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;}
65
+ .message.bot{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px;white-space:pre-wrap;word-break:break-word;}
66
+ .msg-section{margin-top:10px;font-weight:700;font-size:11px;color:var(--accent);letter-spacing:0.05em;text-transform:uppercase;}
67
+ .msg-item{display:flex;gap:8px;margin-top:5px;} .msg-item-num{font-weight:700;color:var(--accent);min-width:16px;} .msg-bullet{color:var(--accent);min-width:14px;}
68
+ .chat-table-wrap{max-width:100%;overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface2);margin-top:4px;}
69
+ .chat-table-title{padding:8px 12px;font-size:11px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);letter-spacing:0.05em;text-transform:uppercase;}
70
+ .chat-table{width:100%;border-collapse:collapse;font-size:12px;}
71
+ .chat-table th{padding:7px 12px;text-align:left;font-weight:700;font-size:11px;color:var(--muted2);background:var(--bg);border-bottom:1px solid var(--border);white-space:nowrap;}
72
+ .chat-table td{padding:7px 12px;border-bottom:1px solid var(--border);color:var(--text);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .chat-table tr:last-child td{border-bottom:none;} .chat-table tr:hover td{background:var(--surface);}
73
+ .typing-indicator{display:flex;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:16px;width:fit-content;}
74
+ .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.4s infinite;} .dot:nth-child(2){animation-delay:0.2s;} .dot:nth-child(3){animation-delay:0.4s;}
75
+ @keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
76
+ .chat-input-area{padding:14px 16px;background:var(--surface);border-top:1px solid var(--border);display:flex;gap:10px;flex-shrink:0;}
77
+ #chat-input{flex:1;background:var(--bg);border:1px solid var(--border);color:white;padding:10px 14px;border-radius:12px;font-size:13px;outline:none;} #chat-input:focus{border-color:var(--accent);}
78
+ .btn-send{width:40px;height:40px;background:var(--accent);color:white;border:none;border-radius:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:0.2s;flex-shrink:0;} .btn-send:hover{transform:scale(1.05);} .btn-send svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.5;}
79
+ .chat-suggestions{display:flex;flex-wrap:wrap;gap:6px;padding:0 16px 10px;}
80
+ .sug-chip{font-size:11px;padding:5px 10px;border-radius:20px;background:var(--surface2);border:1px solid var(--border);color:var(--muted2);cursor:pointer;transition:0.2s;} .sug-chip:hover{border-color:var(--accent);color:var(--accent);}
 
 
 
 
 
 
 
 
 
 
 
 
81
  </style>
82
  </head>
83
  <body>
 
87
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
88
  BATCH INTEL
89
  </a>
90
+ <nav style="margin-left:30px;display:flex;gap:20px;">
91
+ <a href="/" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
92
+ <a href="/scraper" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Single Explorer</a>
93
+ <a href="/batch" style="color:var(--text);text-decoration:none;font-size:13px;font-weight:700;border-bottom:2px solid var(--accent);padding-bottom:4px;">Batch Intelligence</a>
94
  </nav>
95
  <div style="flex:1"></div>
96
+ <button onclick="downloadCSV()" style="background:var(--surface2);border:1px solid var(--border);color:white;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;font-weight:700;">Export Combined CSV</button>
97
  </div>
98
 
99
  <div class="main">
 
102
  <div class="label">Step 1: Discover Apps</div>
103
  <div style="display:flex;gap:8px;">
104
  <input type="text" id="query" placeholder="e.g. Multiplayer Games..." value="Multiplayer Games" style="flex:1">
105
+ <button onclick="findApps()" id="btnFind" style="background:var(--accent);border:none;color:white;padding:0 15px;border-radius:8px;cursor:pointer;font-weight:700;">Find</button>
106
  </div>
107
  </div>
 
108
  <div class="input-group">
109
  <div class="label">Discovery Limit</div>
110
  <input type="number" id="app_count" value="10" min="1" max="50">
 
111
  </div>
112
+ <div id="selectionArea" class="hidden" style="background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:8px;">
113
+ <div class="label" style="display:flex;justify-content:space-between;align-items:center;"><span>Select Apps</span><span id="selectedCount" style="color:var(--accent);font-size:9px;">0 selected</span></div>
114
+ <div id="appList" style="max-height:160px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:4px;padding-right:4px;"></div>
115
+ <div style="display:flex;gap:5px;"><button onclick="toggleAllApps(true)" class="btn-sm" style="flex:1">All</button><button onclick="toggleAllApps(false)" class="btn-sm" style="flex:1">None</button></div>
 
 
 
 
 
 
 
 
 
116
  </div>
 
117
  <div class="input-group">
118
  <div class="label">Step 2: Scrape Settings</div>
119
+ <div class="label" style="font-size:10px;margin-top:10px;">Reviews Per App</div>
120
  <div class="mode-toggle">
121
  <div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div>
122
  <div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div>
123
  </div>
124
  <input type="number" id="reviews_per_app" value="50" min="10" step="10">
125
  </div>
 
126
  <div class="input-group">
127
  <div class="label">Sort Method</div>
128
+ <select id="sort"><option value="MOST_RELEVANT">Most Relevant</option><option value="NEWEST">Newest</option><option value="RATING">Top Ratings</option></select>
 
 
 
 
129
  </div>
 
130
  <div class="input-group">
131
+ <div class="label" style="display:flex;justify-content:space-between;align-items:center;">
132
  <span>Star Rating Filter</span>
133
+ <div style="display:flex;gap:5px;">
134
+ <button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(true)">All</button>
135
+ <button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(false)">None</button>
136
  </div>
137
  </div>
138
  <div class="star-filter-grid">
 
143
  <label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">★</span><span class="stars-off">★★★★</span></span></label>
144
  </div>
145
  </div>
 
146
  <button class="btn-main" id="go" onclick="runBatch()">
147
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
148
  RUN BATCH ANALYSIS
149
  </button>
150
+ <div style="background:var(--bg);padding:15px;border-radius:12px;border:1px solid var(--border);font-size:11px;color:var(--muted);line-height:1.6;"><strong style="color:var(--text)">About Batch Mode</strong><br>Search for apps, scrape reviews for each, and compare them side-by-side with AI chat support.</div>
 
 
 
 
151
  </aside>
152
 
153
  <div class="content">
 
156
  <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
157
  <p>Run batch analysis to compare app data</p>
158
  </div>
 
159
  <div id="results" class="hidden">
160
  <div class="batch-summary">
161
  <div class="label">Comparing These Apps:</div>
162
  <div class="apps-grid" id="appsGrid"></div>
163
  </div>
164
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
165
+ <div class="label">Reviews Comparison</div>
166
+ <div style="font-size:11px;color:var(--muted);" id="resultStats"></div>
167
+ </div>
168
  <div class="table-container">
169
  <table id="reviewsTable">
170
  <thead>
171
  <tr>
172
+ <th style="width:180px">Application / User</th>
173
+ <th style="width:90px">Score</th>
174
+ <th>Feedback & Developer Response</th>
175
+ <th style="width:100px">Helpful</th>
176
+ <th style="width:110px">Date</th>
177
  </tr>
178
  </thead>
179
  <tbody id="reviewsBody"></tbody>
 
181
  </div>
182
  </div>
183
  </div>
 
184
  <div id="loader" class="loader-overlay hidden">
185
  <div class="spinner"></div>
186
  <p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p>
 
188
  </div>
189
  </div>
190
 
191
+ <!-- Chat bubble -->
192
+ <div id="chat-dialer" onclick="toggleChat()">
193
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
194
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
+ <div id="chat-window">
197
+ <div class="chat-header">
198
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
199
+ <div class="chat-header-info">
200
+ <div class="chat-header-title">PlayPulse Intelligence</div>
201
+ <div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
202
+ </div>
203
+ <div class="chat-header-actions">
204
+ <button class="chat-clear-btn" onclick="clearChat()">Clear</button>
205
+ <div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
206
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ <div class="chat-messages" id="chat-messages">
211
+ <div class="msg-row bot">
212
+ <div class="message bot">👋 Hi! I'm PlayPulse Intelligence. Run a batch scrape, then ask me to compare apps, find issues, show tables, or analyze sentiment!</div>
213
+ </div>
214
+ </div>
215
+ <div class="chat-suggestions" id="chat-sug">
216
+ <div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div>
217
+ <div class="sug-chip" onclick="fillChat('Which app has the most complaints?')">Most complaints</div>
218
+ <div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1★ table</div>
219
+ <div class="sug-chip" onclick="fillChat('What are the common issues?')">Common issues</div>
220
+ </div>
221
+ <div class="chat-input-area">
222
+ <input type="text" id="chat-input" placeholder="Ask about the batch analysis…" onkeydown="if(event.key==='Enter') sendChatMessage()">
223
+ <button class="btn-send" onclick="sendChatMessage()">
224
+ <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
225
+ </button>
226
+ </div>
227
+ </div>
228
 
229
+ <script>
230
+ let currentData=null;
231
+ let currentMode='fixed';
232
+
233
+ function setMode(m){currentMode=m;document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));document.getElementById('btn-'+m).classList.add('active');document.getElementById('reviews_per_app').classList.toggle('hidden',m==='all');}
234
+ function selectAllStars(check){document.querySelectorAll('.star-cb').forEach(cb=>cb.checked=check);}
235
+
236
+ let foundApps=[];
237
+ async function findApps(){
238
+ const q=document.getElementById('query').value.trim();if(!q)return;
239
+ const btn=document.getElementById('btnFind');btn.disabled=true;btn.innerText='Searching...';
240
+ try{
241
+ const res=await fetch('/find-apps',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q,app_count:document.getElementById('app_count').value})});
242
+ const data=await res.json();if(!res.ok)throw new Error(data.error||'Discovery failed');
243
+ foundApps=data.results;renderAppSelection();
244
+ }catch(e){alert(e.message);}finally{btn.disabled=false;btn.innerText='Find';}
245
  }
246
 
247
+ function renderAppSelection(){
248
+ const list=document.getElementById('appList');
249
  document.getElementById('selectionArea').classList.remove('hidden');
250
+ list.innerHTML=foundApps.map(a=>`<label style="display:flex;align-items:center;gap:8px;padding:6px;background:var(--bg);border-radius:6px;border:1px solid var(--border);cursor:pointer;min-width:0;"><input type="checkbox" class="app-cb" value="${a.appId}" onchange="updateSelectionCount()" checked style="width:14px;height:14px;margin:0;"><img src="${a.icon}" style="width:20px;height:20px;border-radius:4px;flex-shrink:0;"><div style="flex:1;min-width:0;"><div style="font-size:10px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);">${a.title}</div><div style="font-size:9px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${a.developer}</div></div></label>`).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  updateSelectionCount();
252
  }
253
 
254
+ function toggleAllApps(check){document.querySelectorAll('.app-cb').forEach(cb=>cb.checked=check);updateSelectionCount();}
255
+ function updateSelectionCount(){const count=document.querySelectorAll('.app-cb:checked').length;document.getElementById('selectedCount').innerText=`${count} selected`;document.getElementById('go').disabled=count===0;}
256
+
257
+ async function runBatch(){
258
+ const selectedAppIds=[...document.querySelectorAll('.app-cb:checked')].map(cb=>cb.value);
259
+ if(!selectedAppIds.length)return alert('Select at least one app');
260
+ const stars=[...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
261
+ if(!stars.length)return alert('Select at least one star rating');
262
+ document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.add('hidden');
263
+ document.getElementById('loader').classList.remove('hidden');document.getElementById('go').disabled=true;
264
+ try{
265
+ const res=await fetch('/scrape-batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({app_ids:selectedAppIds,review_count_type:currentMode,reviews_per_app:document.getElementById('reviews_per_app').value,sort_order:document.getElementById('sort').value,star_ratings:stars.length===5?'all':stars})});
266
+ const data=await res.json();if(!res.ok)throw new Error(data.error||'Batch failed');
267
+ currentData=data;render(data);
268
+ }catch(e){alert(e.message);}finally{document.getElementById('loader').classList.add('hidden');document.getElementById('go').disabled=false;}
269
  }
270
 
271
+ function render(data,customReviews){
272
+ const reviews=customReviews||data.reviews;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  document.getElementById('results').classList.remove('hidden');
274
+ document.getElementById('appsGrid').innerHTML=data.apps.map(a=>`<div class="app-mini-card"><img src="${a.icon}" alt=""><div class="app-mini-info"><div class="app-mini-title">${a.title}</div><div class="app-mini-score">${a.score.toFixed(1)} ★</div></div></div>`).join('');
275
+ document.getElementById('resultStats').innerText=`Found ${reviews.length} reviews`;
276
 
277
+ document.getElementById('reviewsBody').innerHTML=reviews.map(r=>{
278
+ const app=data.apps.find(a=>a.appId===r.appId)||{title:r.appTitle};
279
+ const replyHtml = r.replyContent ? `<div class="dev-reply"><span class="dev-reply-label">Developer Reply</span>${r.replyContent}</div>` : '';
280
+ const helpfulHtml = `<div class="helpful-pill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>${r.thumbsUpCount || 0}</div>`;
281
+
 
 
 
 
 
 
 
 
 
282
  return `
283
  <tr>
284
  <td>
285
  <div class="app-tag">${app.title}</div>
286
+ <div style="font-size:11px;font-weight:700;color:var(--text);">${r.userName}</div>
287
  </td>
288
  <td>
289
+ <div class="score-stars">${'★'.repeat(r.score)}<span style="color:var(--border)">${'★'.repeat(5-r.score)}</span></div>
290
  </td>
291
  <td>
292
  <div class="review-content">${r.content}</div>
293
+ ${replyHtml}
294
  </td>
295
+ <td>${helpfulHtml}</td>
296
+ <td><div style="color:var(--muted);font-size:11px;">${new Date(r.at).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'})}</div></td>
 
297
  </tr>
298
  `;
299
  }).join('');
300
  }
301
 
302
+ function downloadCSV(){
303
+ if(!currentData)return;
304
+ const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
305
+ const hdr=['App Name','App ID','User','Score','Date','Content','Thumbs Up','Developer Reply'];
306
+ const rows=currentData.reviews.map(r=>[esc(r.appTitle),esc(r.appId),esc(r.userName),r.score,esc(r.at.slice(0,10)),esc(r.content),r.thumbsUpCount,esc(r.replyContent)].join(','));
307
+ const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
308
+ const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`batch_${Date.now()}.csv`});a.click();
309
+ }
310
+
311
+ // ══════════════════════════════════════════════════════════════════════
312
+ // CHAT (shared logic identical to index.html)
313
+ // ══════════════════════════════════════════════════════════════════════
314
+ const SESSION_ID=(()=>{let id=sessionStorage.getItem('pp_sid');if(!id){id='sess_'+Math.random().toString(36).slice(2);sessionStorage.setItem('pp_sid',id);}return id;})();
315
+
316
+ function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
317
+ function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();}
318
 
319
+ async function clearChat(){
320
+ document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared. Run a batch scrape then ask me anything!</div></div>`;
321
+ await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
 
 
 
322
  }
 
323
 
324
+ async function sendChatMessage(){
325
+ const input=document.getElementById('chat-input');const msg=input.value.trim();if(!msg)return;
326
+ appendUserMsg(msg);input.value='';
327
+ const container=document.getElementById('chat-messages');
328
+ const typing=document.createElement('div');typing.className='typing-indicator';typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
329
+ container.appendChild(typing);container.scrollTop=container.scrollHeight;
330
+ try{
331
+ const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:(currentData&&currentData.reviews)?currentData.reviews:[]})});
332
+ const data=await res.json();
333
+ if(typing.parentNode)container.removeChild(typing);
334
+ if(data.error){appendBotMsg('⚠️ '+data.error,null);return;}
335
+ appendBotMsg(data.reply||'',data.table||null);
336
+ if(data.type==='filter'&&data.filters)applyChatFilters(data.filters);
337
+ }catch(e){if(typing.parentNode)container.removeChild(typing);appendBotMsg('Connection error.',null);}
338
+ }
339
+
340
+ function appendUserMsg(text){const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row user';row.innerHTML=`<div class="message user">${escHtml(text)}</div>`;c.appendChild(row);c.scrollTop=c.scrollHeight;}
341
+
342
+ function appendBotMsg(text,table){
343
+ const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row bot';
344
+ if(text&&text.trim()){const b=document.createElement('div');b.className='message bot';b.innerHTML=renderMD(text);row.appendChild(b);}
345
+ if(table&&table.rows&&table.rows.length){row.appendChild(buildTable(table));}
346
+ c.appendChild(row);c.scrollTop=c.scrollHeight;
347
+ }
348
+
349
+ function renderMD(text){
350
+ const lines=text.split('\n');let html='',inList=false;
351
+ for(let raw of lines){
352
+ if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;}
353
+ const nm=raw.match(/^(\d+)\.\s+(.+)/);if(nm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${inlineFmt(nm[2])}</span></div>`;continue;}
354
+ const bm=raw.match(/^[•\-\*]\s+(.+)/);if(bm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">•</span><span>${inlineFmt(bm[1])}</span></div>`;continue;}
355
+ if(inList&&raw.trim()===''){html+='</div>';inList=false;}
356
+ if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
357
+ }
358
+ if(inList)html+='</div>';return html;
359
+ }
360
+ function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');}
361
+ function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
362
+
363
+ function buildTable(td){
364
+ const{title,columns,rows}=td;const w=document.createElement('div');w.className='chat-table-wrap';
365
+ let h='';if(title)h+=`<div class="chat-table-title">${escHtml(title)}</div>`;
366
+ h+='<table class="chat-table"><thead><tr>';for(const c of columns)h+=`<th>${escHtml(c)}</th>`;h+='</tr></thead><tbody>';
367
+ for(const row of rows){h+='<tr>';for(const c of columns){const v=row[c]!==undefined?row[c]:'';h+=`<td title="${escHtml(String(v))}">${escHtml(String(v))}</td>`;}h+='</tr>';}
368
+ h+='</tbody></table>';w.innerHTML=h;return w;
369
+ }
370
+
371
+ function applyChatFilters(raw){
372
+ if(!currentData)return;
373
+ try{
374
+ const f=typeof raw==='string'?JSON.parse(raw):raw;let filtered=currentData.reviews;
375
+ if(f.stars&&f.stars.length)filtered=filtered.filter(r=>f.stars.includes(r.score));
376
+ if(f.app){const q=f.app.toLowerCase();filtered=filtered.filter(r=>{const app=currentData.apps.find(a=>a.appId===r.appId)||{title:r.appTitle||""};return(app.title||"").toLowerCase().includes(q)||r.appId.toLowerCase().includes(q);});}
377
+ if(f.query){const q=f.query.toLowerCase();filtered=filtered.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));}
378
+ render(currentData,filtered);
379
+ }catch(e){console.error('Filter error',e);}
380
+ }
381
+ </script>
382
  </body>
383
+ </html>
templates/index.html CHANGED
@@ -21,21 +21,16 @@
21
  --muted2: #94a3b8;
22
  }
23
  * { box-sizing: border-box; margin: 0; padding: 0; }
24
-
25
- /* Modern Scrollbar */
26
  ::-webkit-scrollbar { width: 6px; height: 6px; }
27
  ::-webkit-scrollbar-track { background: transparent; }
28
  ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
29
  ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
30
  * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
31
-
32
  body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
33
-
34
  .header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
35
  .main { flex: 1; display: flex; overflow: hidden; }
36
  .sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
37
  .content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
38
-
39
  .logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
40
  .input-group { display: flex; flex-direction: column; gap: 8px; }
41
  .label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; display:flex; align-items:center; justify-content:space-between; }
@@ -50,12 +45,9 @@
50
  .btn-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; flex-shrink: 0; }
51
  .btn-icon:hover { color: white; border-color: var(--accent); }
52
  .btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
53
-
54
  .view-tabs { display: flex; gap: 10px; }
55
  .tab { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 700; cursor: pointer; border: 1px solid var(--border); color: var(--muted); transition: 0.2s; }
56
  .tab.active { background: var(--accent); color: white; border-color: var(--accent); }
57
-
58
- /* Star Filter */
59
  .star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
60
  .star-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; transition: border-color 0.15s; user-select: none; }
61
  .star-row:hover { border-color: var(--accent); }
@@ -67,26 +59,18 @@
67
  .quick-btn:hover { color: white; border-color: var(--accent); }
68
  .filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
69
  .chip { font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 20px; background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(59,130,246,.3); }
70
-
71
- /* Layout */
72
  .scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
73
-
74
- /* App header card */
75
  .app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 16px; display: flex; gap: 20px; }
76
  .app-card img { width: 80px; height: 80px; border-radius: 16px; object-fit: cover; }
77
  .app-stats { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
78
  .stat-pill { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; display: flex; flex-direction: column; align-items: center; min-width: 58px; }
79
  .stat-val { font-size: 15px; font-weight: 800; line-height: 1; }
80
  .stat-key { font-size: 9px; font-weight: 700; text-transform: uppercase; color: var(--muted); margin-top: 3px; letter-spacing: .5px; }
81
-
82
- /* Summary bar */
83
  .summary-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
84
  .star-dist { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 160px; }
85
  .star-bar-row { display: flex; align-items: center; gap: 7px; font-size: 11px; }
86
  .star-bar-track { flex: 1; height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
87
  .star-bar-fill { height: 100%; border-radius: 3px; background: var(--amber); }
88
-
89
- /* Review card */
90
  .review-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
91
  .review-card:hover { border-color: #2d3a4f; }
92
  .review-main { padding: 16px 18px; }
@@ -98,125 +82,37 @@
98
  .review-date { font-size: 11px; color: var(--muted); margin-top: 1px; }
99
  .review-stars { display: flex; gap: 1px; flex-shrink: 0; }
100
  .review-text { font-size: 13px; color: #cbd5e1; line-height: 1.6; }
101
-
102
- /* Meta pills row */
103
  .review-footer { padding: 10px 18px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
104
  .meta-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; padding: 4px 9px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted2); background: var(--surface); }
105
  .meta-pill svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.5; flex-shrink: 0; }
106
  .meta-pill.thumbs { color: #3b82f6; border-color: rgba(59,130,246,.25); background: var(--accent-dim); }
107
  .meta-pill.version { color: #a78bfa; border-color: rgba(167,139,250,.25); background: rgba(167,139,250,.08); }
108
  .meta-pill.replied { color: var(--green); border-color: rgba(34,197,94,.25); background: var(--green-dim); }
109
-
110
- /* Dev reply block */
111
  .dev-reply { margin: 0 18px 16px; background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 10px; padding: 12px 14px; }
112
  .dev-reply-header { font-size: 11px; font-weight: 700; color: var(--green); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
113
  .dev-reply-text { font-size: 12px; color: var(--muted2); line-height: 1.55; }
114
  .dev-reply-date { font-size: 10px; color: var(--muted); margin-top: 5px; }
115
-
116
- /* Overlays */
117
  .loader-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
118
  .spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
119
  .site-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; padding: 40px; }
120
  .site-overlay h3 { font-size: 18px; font-weight: 700; }
121
  .site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
122
-
123
  .hidden { display: none !important; }
124
  @keyframes spin { to { transform: rotate(360deg); } }
125
-
126
- /* Search Suggestions Overlay */
127
  .search-wrap { position: relative; }
128
- .suggestions-box {
129
- position: absolute;
130
- top: calc(100% + 8px);
131
- left: 0;
132
- right: 0;
133
- background: var(--surface2);
134
- border: 1px solid var(--border);
135
- border-radius: 12px;
136
- z-index: 1000;
137
- max-height: 400px;
138
- overflow-y: auto;
139
- box-shadow: 0 15px 40px rgba(0,0,0,0.6);
140
- backdrop-filter: blur(10px);
141
- }
142
- .suggestion-item {
143
- display: flex;
144
- align-items: center;
145
- padding: 12px 14px;
146
- gap: 12px;
147
- cursor: pointer;
148
- transition: .2s cubic-bezier(0.4, 0, 0.2, 1);
149
- border-bottom: 1px solid var(--border);
150
- }
151
  .suggestion-item:last-child { border-bottom: none; }
152
- .suggestion-item:hover { background: var(--accent-dim); border-color: var(--accent); }
153
- .suggestion-item img {
154
- width: 44px;
155
- height: 44px;
156
- border-radius: 10px;
157
- object-fit: cover;
158
- border: 1px solid var(--border);
159
- }
160
  .suggestion-info { flex: 1; min-width: 0; }
161
- .suggestion-title {
162
- font-size: 13px;
163
- font-weight: 700;
164
- white-space: nowrap;
165
- overflow: hidden;
166
- text-overflow: ellipsis;
167
- color: var(--text);
168
- }
169
- .suggestion-sub {
170
- font-size: 11px;
171
- color: var(--muted);
172
- margin-top: 2px;
173
- white-space: nowrap;
174
- overflow: hidden;
175
- text-overflow: ellipsis;
176
- }
177
- .suggestion-score {
178
- font-size: 11px;
179
- font-weight: 700;
180
- color: var(--amber);
181
- background: rgba(245, 158, 11, 0.1);
182
- padding: 2px 6px;
183
- border-radius: 4px;
184
- }
185
- .suggestion-loading {
186
- padding: 30px 20px;
187
- text-align: center;
188
- color: var(--muted);
189
- font-size: 12px;
190
- display: flex;
191
- flex-direction: column;
192
- align-items: center;
193
- gap: 10px;
194
- }
195
- .suggestion-loading .spinner-small {
196
- width: 20px;
197
- height: 20px;
198
- border: 2px solid var(--border);
199
- border-top-color: var(--accent);
200
- border-radius: 50%;
201
- animation: spin .8s linear infinite;
202
- }
203
-
204
- /* Search Section Page Styles */
205
- .search-results-grid {
206
- display: grid;
207
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
208
- gap: 15px;
209
- }
210
- .search-app-card {
211
- background: var(--surface);
212
- border: 1px solid var(--border);
213
- border-radius: 16px;
214
- padding: 16px;
215
- display: flex;
216
- gap: 15px;
217
- cursor: pointer;
218
- transition: .2s;
219
- }
220
  .search-app-card:hover { border-color: var(--accent); background: var(--surface2); transform: translateY(-2px); }
221
  .search-app-card img { width: 60px; height: 60px; border-radius: 12px; }
222
  .search-app-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
@@ -225,19 +121,66 @@
225
  .search-app-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
226
  .search-app-score { color: var(--amber); }
227
  .search-app-installs { color: var(--muted2); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  </style>
229
  </head>
230
  <body>
231
 
232
  <div class="header">
233
- <a href="/" class="logo" style="text-decoration: none;">
234
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
235
  PLAYPULSE
236
  </a>
237
- <nav style="margin-left: 30px; display: flex; gap: 20px;">
238
- <a href="/" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
239
- <a href="/scraper" style="color: var(--text); text-decoration: none; font-size: 13px; font-weight: 700; border-bottom: 2px solid var(--accent); padding-bottom: 4px;">Single Explorer</a>
240
- <a href="/batch" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Batch Intelligence</a>
241
  </nav>
242
  <div style="flex:1"></div>
243
  <div class="view-tabs">
@@ -258,7 +201,6 @@
258
  <div class="suggestions-box hidden" id="suggestionsBox"></div>
259
  </div>
260
  </div>
261
-
262
  <div class="input-group">
263
  <div class="label">Amount of Data</div>
264
  <div class="toggle-grp">
@@ -267,7 +209,6 @@
267
  </div>
268
  <input type="number" id="manualCount" value="200" placeholder="Count (e.g. 500)">
269
  </div>
270
-
271
  <div class="input-group">
272
  <div class="label">Strategy</div>
273
  <select id="sort">
@@ -276,11 +217,10 @@
276
  <option value="RATING">Top Ratings</option>
277
  </select>
278
  </div>
279
-
280
  <div class="input-group">
281
  <div class="label">
282
  <span>Star Rating Filter</span>
283
- <div style="display:flex;gap:5px">
284
  <button class="quick-btn" onclick="selectAllStars(true)">All</button>
285
  <button class="quick-btn" onclick="selectAllStars(false)">None</button>
286
  </div>
@@ -294,12 +234,10 @@
294
  </div>
295
  <div class="filter-chips" id="filterChips"></div>
296
  </div>
297
-
298
  <button class="btn-main" id="go" onclick="run()">
299
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
300
  START SCRAPING
301
  </button>
302
-
303
  <div class="input-group">
304
  <div class="label">Recent Sessions</div>
305
  <div id="recentList" style="display:flex;flex-direction:column;gap:8px;"></div>
@@ -314,16 +252,14 @@
314
  </div>
315
  <div id="results" class="hidden"></div>
316
  </div>
317
-
318
  <div id="loader" class="loader-overlay hidden">
319
  <div class="spinner"></div>
320
  <p style="color:var(--muted);font-size:14px" id="loaderMsg">Connecting to servers…</p>
321
  </div>
322
-
323
  <div id="siteView" class="hidden" style="height:100%">
324
  <div class="site-overlay">
325
  <h3>Web View Shielded</h3>
326
- <p>Google Play Store blocks previewing inside other apps for security. Use this button to view the live site in a new tab.</p>
327
  <button class="btn-main" style="width:auto;padding:12px 24px" onclick="openTarget()">
328
  Open on Google Play
329
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
@@ -333,6 +269,44 @@
333
  </div>
334
  </div>
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  <script>
337
  let mode = 'limit';
338
  let currentData = null;
@@ -343,20 +317,17 @@
343
  document.getElementById('btnLimit').classList.toggle('active', m==='limit');
344
  document.getElementById('manualCount').classList.toggle('hidden', m==='all');
345
  }
346
-
347
  function switchView(v, event) {
348
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
349
  event.target.classList.add('active');
350
  document.getElementById('dataView').classList.toggle('hidden', v!=='data');
351
  document.getElementById('siteView').classList.toggle('hidden', v!=='site');
352
  }
353
-
354
  function openTarget() {
355
  const url = document.getElementById('target').value;
356
  if (url.startsWith('http')) window.open(url,'_blank');
357
  else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`,'_blank');
358
  }
359
-
360
  function selectAllStars(checked) {
361
  document.querySelectorAll('.star-cb').forEach(cb => cb.checked = checked);
362
  updateChips();
@@ -370,31 +341,19 @@
370
  return [...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
371
  }
372
  document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
373
-
374
  function renderStars(score) {
375
- let out = '';
376
- for (let i=1;i<=5;i++) out += `<span style="font-size:13px;color:${i<=score?'var(--amber)':'var(--border)'}">${i<=score?'':'★'}</span>`;
377
  return out;
378
  }
379
-
380
- function fmtDate(iso) {
381
- if (!iso) return '';
382
- return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'});
383
- }
384
-
385
- function fmtNum(n) {
386
- if (!n) return null;
387
- if (n>=1000000) return (n/1000000).toFixed(1)+'M';
388
- if (n>=1000) return (n/1000).toFixed(1)+'k';
389
- return String(n);
390
- }
391
 
392
  async function run() {
393
- const query = document.getElementById('target').value.trim();
394
- if (!query) return;
395
- const selectedStars = getSelectedStars();
396
- if (!selectedStars.length) { alert('Select at least one star rating.'); return; }
397
-
398
  document.getElementById('results').innerHTML='';
399
  currentData=null;
400
  document.getElementById('welcome').classList.add('hidden');
@@ -402,41 +361,21 @@
402
  document.getElementById('loader').classList.remove('hidden');
403
  hideSuggestions();
404
  document.getElementById('go').disabled=true;
405
-
406
  const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
407
  let mi=0;
408
  document.getElementById('loaderMsg').textContent=msgs[0];
409
  const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2200);
410
-
411
  try {
412
- const res = await fetch('/scrape',{
413
- method:'POST',
414
- headers:{'Content-Type':'application/json'},
415
- body:JSON.stringify({
416
- identifier:query,
417
- review_count_type:mode,
418
- review_count:parseInt(document.getElementById('manualCount').value)||200,
419
- sort_order:document.getElementById('sort').value,
420
- star_ratings:selectedStars.length===5?'all':selectedStars
421
- })
422
- });
423
  const data=await res.json();
424
- if (!res.ok) throw new Error(data.error || 'Scraping failed');
425
-
426
  currentData=data;
427
- document.getElementById('results').classList.remove('hidden'); // CRITICAL FIX: show the div!
428
  render(data,selectedStars);
429
  save(data.app_info);
430
  } catch(e) {
431
- document.getElementById('results').classList.remove('hidden'); // Also show for errors
432
- document.getElementById('results').innerHTML = `
433
- <div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:14px;padding:24px 28px;display:flex;flex-direction:column;gap:10px">
434
- <div style="display:flex;align-items:center;gap:10px">
435
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
436
- <span style="font-weight:700;color:#ef4444;font-size:14px">Operation Failed</span>
437
- </div>
438
- <p style="color:var(--muted2);font-size:13px;line-height:1.6">${e.message}</p>
439
- </div>`;
440
  } finally {
441
  clearInterval(msgInt);
442
  document.getElementById('loader').classList.add('hidden');
@@ -444,10 +383,9 @@
444
  }
445
  }
446
 
447
- function render(data, selectedStars) {
448
- const { reviews, app_info: info } = data;
449
-
450
- // Compute stats
451
  const dist={1:0,2:0,3:0,4:0,5:0};
452
  reviews.forEach(r=>{if(r.score>=1&&r.score<=5)dist[r.score]++;});
453
  const total=reviews.length;
@@ -455,122 +393,35 @@
455
  const avgScore=total?(reviews.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2):'—';
456
  const totalLikes=reviews.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
457
  const filterLabel=selectedStars.length===5?'All Ratings':selectedStars.sort((a,b)=>b-a).map(s=>`${s}★`).join(', ');
458
-
459
- // Star distribution bars
460
- const starDistHTML=[5,4,3,2,1].map(s=>{
461
- const pct=total?Math.round((dist[s]/total)*100):0;
462
- return `<div class="star-bar-row">
463
- <span style="color:var(--amber);width:12px;text-align:right">${s}</span>
464
- <div class="star-bar-track"><div class="star-bar-fill" style="width:${pct}%"></div></div>
465
- <span style="color:var(--muted);width:30px;text-align:right">${pct}%</span>
466
- </div>`;
467
- }).join('');
468
-
469
- // Individual review cards
470
  const reviewsHTML=reviews.map(r=>{
471
  const thumbsLabel=fmtNum(r.thumbsUpCount);
472
  const hasReply=r.replyContent&&r.replyContent.trim();
473
  const version=r.reviewCreatedVersion;
474
-
475
- // Build meta pills — only if data exists
476
  const pills=[
477
- thumbsLabel ? `<span class="meta-pill thumbs">
478
- <svg viewBox="0 0 24 24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
479
- ${thumbsLabel} helpful
480
- </span>` : '',
481
- version ? `<span class="meta-pill version">
482
- <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
483
- v${version}
484
- </span>` : '',
485
- hasReply ? `<span class="meta-pill replied">
486
- <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
487
- Dev replied
488
- </span>` : ''
489
  ].filter(Boolean).join('');
490
-
491
- // Developer reply block
492
- const replyHTML=hasReply?`
493
- <div class="dev-reply">
494
- <div class="dev-reply-header">
495
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
496
- Developer Response
497
- </div>
498
- <div class="dev-reply-text">${r.replyContent}</div>
499
- ${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}
500
- </div>`:'';
501
-
502
- // User avatar
503
  const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
504
- const avatarHTML=r.userImage
505
- ?`<div class="user-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>`
506
- :`<div class="user-avatar">${initials}</div>`;
507
-
508
- return `
509
- <div class="review-card">
510
- <div class="review-main">
511
- <div class="review-header">
512
- ${avatarHTML}
513
- <div class="review-meta">
514
- <div class="review-username">${r.userName||'Anonymous'}</div>
515
- <div class="review-date">${fmtDate(r.at)}</div>
516
- </div>
517
- <div class="review-stars">${renderStars(r.score)}</div>
518
- </div>
519
- <div class="review-text">${r.content||'<em style="color:var(--muted)">No review text</em>'}</div>
520
- </div>
521
- ${pills?`<div class="review-footer">${pills}</div>`:''}
522
- ${replyHTML}
523
- </div>`;
524
  }).join('');
525
-
526
  document.getElementById('results').innerHTML=`
527
- <div class="app-card">
528
- <img src="${info.icon}" alt="icon">
529
- <div style="flex:1">
530
- <h2 style="font-size:20px;font-weight:800;margin-bottom:3px">${info.title}</h2>
531
- <div style="color:var(--accent);font-weight:700;font-size:11px;margin-bottom:10px">${info.appId}</div>
532
- <div class="app-stats">
533
- <div class="stat-pill"><span class="stat-val" style="color:var(--amber)">${(info.score||0).toFixed(1)}</span><span class="stat-key">Store Avg</span></div>
534
- <div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div>
535
- <div class="stat-pill"><span class="stat-val" style="color:var(--green)">${repliedCount}</span><span class="stat-key">Replied</span></div>
536
- <div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(totalLikes)||'0'}</span><span class="stat-key">Total Likes</span></div>
537
- </div>
538
- </div>
539
- </div>
540
-
541
- <div class="summary-bar">
542
- <div>
543
- <div style="font-size:24px;font-weight:800;color:var(--amber)">${avgScore}</div>
544
- <div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.5px;margin-top:2px">Avg Score</div>
545
- </div>
546
- <div class="star-dist">${starDistHTML}</div>
547
- <div style="font-size:11px;color:var(--muted);padding:6px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--border)">
548
- Filter:<br><strong style="color:var(--accent)">${filterLabel}</strong>
549
- </div>
550
- </div>
551
-
552
- <div style="display:flex;flex-direction:column;gap:10px">${reviewsHTML}</div>
553
- `;
554
  }
555
 
556
  function downloadCSV() {
557
- if (!currentData) return;
558
  const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
559
  const hdr=['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
560
- const rows=currentData.reviews.map(r=>[
561
- esc(r.reviewId||''),
562
- esc(r.userName||''),
563
- r.score||0,
564
- esc((r.at||'').slice(0,10)),
565
- esc(r.content||''),
566
- r.thumbsUpCount||0,
567
- esc(r.reviewCreatedVersion||''),
568
- esc(r.replyContent||''),
569
- esc((r.repliedAt||'').slice(0,10))
570
- ].join(','));
571
  const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
572
  const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
573
- a.click(); URL.revokeObjectURL(a.href);
574
  }
575
 
576
  function save(info) {
@@ -579,133 +430,145 @@
579
  localStorage.setItem('scrapes',JSON.stringify(list));
580
  loadRecent();
581
  }
582
-
583
  function loadRecent() {
584
  const list=JSON.parse(localStorage.getItem('scrapes')||'[]');
585
- document.getElementById('recentList').innerHTML=list.map(x=>`
586
- <div onclick="document.getElementById('target').value='${x.appId}';run()" style="cursor:pointer;background:var(--bg);padding:10px;border-radius:8px;display:flex;gap:10px;align-items:center;border:1px solid var(--border);transition:.15s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
587
- <img src="${x.icon}" style="width:26px;height:26px;border-radius:5px" alt="">
588
- <span style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${x.title}</span>
589
- <span style="font-size:10px;color:var(--muted)">${(x.score||0).toFixed(1)}★</span>
590
- </div>`).join('');
591
  }
592
-
593
  loadRecent();
594
 
595
- // ── Live search suggestions ──────────────────────────────────────────
596
- let searchTimer = null;
597
- const targetEl = document.getElementById('target');
598
- const sugBox = document.getElementById('suggestionsBox');
599
-
600
- function hideSuggestions() {
601
- sugBox.classList.add('hidden');
602
- sugBox.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  }
 
 
604
 
605
- targetEl.addEventListener('input', () => {
606
- clearTimeout(searchTimer);
607
- const q = targetEl.value.trim();
 
608
 
609
- // If it looks like a URL or package ID, skip suggestions
610
- if (q.startsWith('http') || /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(q)) {
611
- hideSuggestions(); return;
612
- }
613
- if (q.length < 2) { hideSuggestions(); return; }
614
 
615
- // Show loading state in sidebar
616
- sugBox.classList.remove('hidden');
617
- sugBox.innerHTML = '<div class="suggestion-loading"><div class="spinner-small"></div>Searching…</div>';
 
618
 
619
- // Also show in main section if current view is empty/welcome
620
- if (document.getElementById('results').classList.contains('hidden')) {
621
- document.getElementById('welcome').classList.add('hidden');
622
- document.getElementById('results').classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
  }
624
-
625
- document.getElementById('results').innerHTML = `
626
- <div style="margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
627
- <span style="font-size: 18px; font-weight: 800;">Search Results: "${q}"</span>
628
- </div>
629
- <div class="search-results-grid" id="searchGrid">
630
- <div style="grid-column: 1/-1; padding: 40px; text-align: center;">
631
- <div class="spinner" style="margin: 0 auto 15px;"></div>
632
- <p style="color:var(--muted)">Searching the Play Store...</p>
633
- </div>
634
- </div>
635
- `;
636
-
637
- searchTimer = setTimeout(async () => {
638
- try {
639
- const res = await fetch('/search-suggestions', {
640
- method: 'POST',
641
- headers: { 'Content-Type': 'application/json' },
642
- body: JSON.stringify({ query: q })
643
- });
644
- const data = await res.json();
645
-
646
- if (!data.results || !data.results.length) {
647
- sugBox.innerHTML = '<div class="suggestion-loading">No results found</div>';
648
- return;
649
- }
650
 
651
- sugBox.innerHTML = data.results.map(r => `
652
- <div class="suggestion-item" onclick="selectSuggestion('${r.appId}', '${r.title.replace(/'/g,"\\'")}')">
653
- <img src="${r.icon}" alt="">
654
- <div class="suggestion-info">
655
- <div class="suggestion-title">${r.title}</div>
656
- <div class="suggestion-sub">${r.developer} &bull; ${r.installs}</div>
657
- </div>
658
- <div class="suggestion-score">${r.score > 0 ? r.score + ' ★' : ''}</div>
659
- </div>`).join('');
660
 
661
- // Grid view for the main section
662
- const gridHTML = data.results.map(r => `
663
- <div class="search-app-card" onclick="selectSuggestion('${r.appId}', '${r.title.replace(/'/g,"\\'")}')">
664
- <img src="${r.icon}" alt="">
665
- <div class="search-app-info">
666
- <div>
667
- <div class="search-app-title" title="${r.title}">${r.title}</div>
668
- <div style="font-size:10px; color:var(--accent); font-family:monospace; margin-bottom:4px;">${r.appId}</div>
669
- <div class="search-app-dev">${r.developer}</div>
670
- </div>
671
- <div class="search-app-meta">
672
- <span class="search-app-score">${r.score > 0 ? '★ ' + r.score : 'N/A'}</span>
673
- <span class="search-app-installs">${r.installs}</span>
674
- <a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto; color:var(--muted); font-size:10px; text-decoration:none; display:flex; align-items:center; gap:4px;">
675
- Store <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
676
- </a>
677
- </div>
678
- </div>
679
- </div>
680
- `).join('');
681
-
682
- document.getElementById('searchGrid').innerHTML = gridHTML;
683
 
684
- } catch (err) {
685
- console.error(err);
686
- hideSuggestions();
687
- document.getElementById('results').innerHTML = `<div style="padding: 20px; color: var(--amber);">Failed to load search results.</div>`;
 
 
688
  }
689
- }, 400); // 400ms debounce
690
- });
691
-
692
- function selectSuggestion(appId, title) {
693
- const validId = appId && appId !== 'null' && appId !== 'None' && !appId.includes('None');
694
- const query = validId ? `https://play.google.com/store/apps/details?id=${appId}` : title;
695
- targetEl.value = query;
696
- hideSuggestions();
697
- run(); // auto-start scraping
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  }
699
 
700
- // Hide on click outside
701
- document.addEventListener('click', e => {
702
- if (!targetEl.contains(e.target) && !sugBox.contains(e.target)) hideSuggestions();
703
- });
704
-
705
- targetEl.addEventListener('keydown', e => {
706
- if (e.key === 'Escape') hideSuggestions();
707
- if (e.key === 'Enter') { hideSuggestions(); run(); }
708
- });
 
 
709
  </script>
710
  </body>
711
  </html>
 
21
  --muted2: #94a3b8;
22
  }
23
  * { box-sizing: border-box; margin: 0; padding: 0; }
 
 
24
  ::-webkit-scrollbar { width: 6px; height: 6px; }
25
  ::-webkit-scrollbar-track { background: transparent; }
26
  ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
27
  ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
28
  * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
 
29
  body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
 
30
  .header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
31
  .main { flex: 1; display: flex; overflow: hidden; }
32
  .sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
33
  .content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
 
34
  .logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
35
  .input-group { display: flex; flex-direction: column; gap: 8px; }
36
  .label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; display:flex; align-items:center; justify-content:space-between; }
 
45
  .btn-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; flex-shrink: 0; }
46
  .btn-icon:hover { color: white; border-color: var(--accent); }
47
  .btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
 
48
  .view-tabs { display: flex; gap: 10px; }
49
  .tab { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 700; cursor: pointer; border: 1px solid var(--border); color: var(--muted); transition: 0.2s; }
50
  .tab.active { background: var(--accent); color: white; border-color: var(--accent); }
 
 
51
  .star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
52
  .star-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; transition: border-color 0.15s; user-select: none; }
53
  .star-row:hover { border-color: var(--accent); }
 
59
  .quick-btn:hover { color: white; border-color: var(--accent); }
60
  .filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
61
  .chip { font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 20px; background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(59,130,246,.3); }
 
 
62
  .scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
 
 
63
  .app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 16px; display: flex; gap: 20px; }
64
  .app-card img { width: 80px; height: 80px; border-radius: 16px; object-fit: cover; }
65
  .app-stats { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
66
  .stat-pill { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; display: flex; flex-direction: column; align-items: center; min-width: 58px; }
67
  .stat-val { font-size: 15px; font-weight: 800; line-height: 1; }
68
  .stat-key { font-size: 9px; font-weight: 700; text-transform: uppercase; color: var(--muted); margin-top: 3px; letter-spacing: .5px; }
 
 
69
  .summary-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
70
  .star-dist { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 160px; }
71
  .star-bar-row { display: flex; align-items: center; gap: 7px; font-size: 11px; }
72
  .star-bar-track { flex: 1; height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
73
  .star-bar-fill { height: 100%; border-radius: 3px; background: var(--amber); }
 
 
74
  .review-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
75
  .review-card:hover { border-color: #2d3a4f; }
76
  .review-main { padding: 16px 18px; }
 
82
  .review-date { font-size: 11px; color: var(--muted); margin-top: 1px; }
83
  .review-stars { display: flex; gap: 1px; flex-shrink: 0; }
84
  .review-text { font-size: 13px; color: #cbd5e1; line-height: 1.6; }
 
 
85
  .review-footer { padding: 10px 18px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
86
  .meta-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; padding: 4px 9px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted2); background: var(--surface); }
87
  .meta-pill svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.5; flex-shrink: 0; }
88
  .meta-pill.thumbs { color: #3b82f6; border-color: rgba(59,130,246,.25); background: var(--accent-dim); }
89
  .meta-pill.version { color: #a78bfa; border-color: rgba(167,139,250,.25); background: rgba(167,139,250,.08); }
90
  .meta-pill.replied { color: var(--green); border-color: rgba(34,197,94,.25); background: var(--green-dim); }
 
 
91
  .dev-reply { margin: 0 18px 16px; background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 10px; padding: 12px 14px; }
92
  .dev-reply-header { font-size: 11px; font-weight: 700; color: var(--green); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
93
  .dev-reply-text { font-size: 12px; color: var(--muted2); line-height: 1.55; }
94
  .dev-reply-date { font-size: 10px; color: var(--muted); margin-top: 5px; }
 
 
95
  .loader-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
96
  .spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
97
  .site-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; padding: 40px; }
98
  .site-overlay h3 { font-size: 18px; font-weight: 700; }
99
  .site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
 
100
  .hidden { display: none !important; }
101
  @keyframes spin { to { transform: rotate(360deg); } }
 
 
102
  .search-wrap { position: relative; }
103
+ .suggestions-box { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; z-index: 1000; max-height: 400px; overflow-y: auto; box-shadow: 0 15px 40px rgba(0,0,0,0.6); backdrop-filter: blur(10px); }
104
+ .suggestion-item { display: flex; align-items: center; padding: 12px 14px; gap: 12px; cursor: pointer; transition: .2s; border-bottom: 1px solid var(--border); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  .suggestion-item:last-child { border-bottom: none; }
106
+ .suggestion-item:hover { background: var(--accent-dim); }
107
+ .suggestion-item img { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; border: 1px solid var(--border); }
 
 
 
 
 
 
108
  .suggestion-info { flex: 1; min-width: 0; }
109
+ .suggestion-title { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
110
+ .suggestion-sub { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
111
+ .suggestion-score { font-size: 11px; font-weight: 700; color: var(--amber); background: rgba(245,158,11,0.1); padding: 2px 6px; border-radius: 4px; }
112
+ .suggestion-loading { padding: 30px 20px; text-align: center; color: var(--muted); font-size: 12px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
113
+ .suggestion-loading .spinner-small { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
114
+ .search-results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 15px; }
115
+ .search-app-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 16px; display: flex; gap: 15px; cursor: pointer; transition: .2s; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  .search-app-card:hover { border-color: var(--accent); background: var(--surface2); transform: translateY(-2px); }
117
  .search-app-card img { width: 60px; height: 60px; border-radius: 12px; }
118
  .search-app-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
 
121
  .search-app-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
122
  .search-app-score { color: var(--amber); }
123
  .search-app-installs { color: var(--muted2); }
124
+
125
+ /* ── Chat styles ── */
126
+ #chat-dialer { position:fixed; bottom:24px; right:24px; width:56px; height:56px; background:var(--accent); border-radius:50%; display:flex; align-items:center; justify-content:center; box-shadow:0 8px 32px rgba(59,130,246,0.4); cursor:pointer; z-index:1000; transition:0.3s cubic-bezier(0.175,0.885,0.32,1.275); border:2px solid rgba(255,255,255,0.1); }
127
+ #chat-dialer:hover { transform:scale(1.1) rotate(5deg); box-shadow:0 12px 40px rgba(59,130,246,0.6); }
128
+ #chat-dialer svg { width:24px; height:24px; color:white; fill:none; stroke:currentColor; stroke-width:2.5; }
129
+ #chat-window { position:fixed; bottom:90px; right:24px; width:420px; height:600px; background:var(--surface); border:1px solid var(--border); border-radius:20px; display:flex; flex-direction:column; box-shadow:0 20px 50px rgba(0,0,0,0.5); z-index:1001; overflow:hidden; transform:translateY(20px) scale(0.95); opacity:0; pointer-events:none; transition:0.3s cubic-bezier(0.4,0,0.2,1); backdrop-filter:blur(20px); }
130
+ #chat-window.open { transform:translateY(0) scale(1); opacity:1; pointer-events:auto; }
131
+ .chat-header { padding:14px 18px; background:var(--accent); color:white; display:flex; align-items:center; gap:12px; flex-shrink:0; }
132
+ .chat-header-info { flex:1; }
133
+ .chat-header-title { font-weight:800; font-size:15px; }
134
+ .chat-header-status { font-size:10px; opacity:0.8; display:flex; align-items:center; gap:4px; }
135
+ .status-dot { width:6px; height:6px; background:#22c55e; border-radius:50%; }
136
+ .chat-header-actions { display:flex; gap:8px; align-items:center; }
137
+ .chat-clear-btn { background:rgba(255,255,255,0.15); border:none; color:white; font-size:11px; padding:4px 10px; border-radius:8px; cursor:pointer; transition:0.2s; }
138
+ .chat-clear-btn:hover { background:rgba(255,255,255,0.25); }
139
+ .chat-messages { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; background-image:radial-gradient(var(--border) 1px,transparent 1px); background-size:20px 20px; }
140
+ .msg-row { display:flex; flex-direction:column; gap:4px; }
141
+ .msg-row.user { align-items:flex-end; }
142
+ .msg-row.bot { align-items:flex-start; }
143
+ .message { max-width:88%; padding:11px 15px; border-radius:16px; font-size:13px; line-height:1.6; }
144
+ .message.user { background:var(--accent); color:white; border-bottom-right-radius:4px; }
145
+ .message.bot { background:var(--surface2); color:var(--text); border:1px solid var(--border); border-bottom-left-radius:4px; white-space:pre-wrap; word-break:break-word; }
146
+ .msg-section { margin-top:10px; font-weight:700; font-size:11px; color:var(--accent); letter-spacing:0.05em; text-transform:uppercase; }
147
+ .msg-item { display:flex; gap:8px; margin-top:5px; }
148
+ .msg-item-num { font-weight:700; color:var(--accent); min-width:16px; }
149
+ .msg-bullet { color:var(--accent); min-width:14px; }
150
+ .chat-table-wrap { max-width:100%; overflow-x:auto; border:1px solid var(--border); border-radius:12px; background:var(--surface2); margin-top:4px; }
151
+ .chat-table-title { padding:8px 12px; font-size:11px; font-weight:700; color:var(--accent); border-bottom:1px solid var(--border); letter-spacing:0.05em; text-transform:uppercase; }
152
+ .chat-table { width:100%; border-collapse:collapse; font-size:12px; }
153
+ .chat-table th { padding:7px 12px; text-align:left; font-weight:700; font-size:11px; color:var(--muted2); background:var(--bg); border-bottom:1px solid var(--border); white-space:nowrap; }
154
+ .chat-table td { padding:7px 12px; border-bottom:1px solid var(--border); color:var(--text); max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
155
+ .chat-table tr:last-child td { border-bottom:none; }
156
+ .chat-table tr:hover td { background:var(--surface); }
157
+ .typing-indicator { display:flex; gap:4px; padding:12px 16px; background:var(--surface2); border:1px solid var(--border); border-radius:16px; width:fit-content; }
158
+ .dot { width:6px; height:6px; background:var(--muted); border-radius:50%; animation:bounce 1.4s infinite; }
159
+ .dot:nth-child(2) { animation-delay:0.2s; }
160
+ .dot:nth-child(3) { animation-delay:0.4s; }
161
+ @keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-6px)} }
162
+ .chat-input-area { padding:14px 16px; background:var(--surface); border-top:1px solid var(--border); display:flex; gap:10px; flex-shrink:0; }
163
+ #chat-input { flex:1; background:var(--bg); border:1px solid var(--border); color:white; padding:10px 14px; border-radius:12px; font-size:13px; outline:none; }
164
+ #chat-input:focus { border-color:var(--accent); }
165
+ .btn-send { width:40px; height:40px; background:var(--accent); color:white; border:none; border-radius:10px; display:flex; align-items:center; justify-content:center; cursor:pointer; transition:0.2s; flex-shrink:0; }
166
+ .btn-send:hover { transform:scale(1.05); }
167
+ .btn-send svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:2.5; }
168
+ .chat-suggestions { display:flex; flex-wrap:wrap; gap:6px; padding:0 16px 10px; }
169
+ .sug-chip { font-size:11px; padding:5px 10px; border-radius:20px; background:var(--surface2); border:1px solid var(--border); color:var(--muted2); cursor:pointer; transition:0.2s; }
170
+ .sug-chip:hover { border-color:var(--accent); color:var(--accent); }
171
  </style>
172
  </head>
173
  <body>
174
 
175
  <div class="header">
176
+ <a href="/" class="logo" style="text-decoration:none;">
177
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
178
  PLAYPULSE
179
  </a>
180
+ <nav style="margin-left:30px;display:flex;gap:20px;">
181
+ <a href="/" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
182
+ <a href="/scraper" style="color:var(--text);text-decoration:none;font-size:13px;font-weight:700;border-bottom:2px solid var(--accent);padding-bottom:4px;">Single Explorer</a>
183
+ <a href="/batch" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Batch Intelligence</a>
184
  </nav>
185
  <div style="flex:1"></div>
186
  <div class="view-tabs">
 
201
  <div class="suggestions-box hidden" id="suggestionsBox"></div>
202
  </div>
203
  </div>
 
204
  <div class="input-group">
205
  <div class="label">Amount of Data</div>
206
  <div class="toggle-grp">
 
209
  </div>
210
  <input type="number" id="manualCount" value="200" placeholder="Count (e.g. 500)">
211
  </div>
 
212
  <div class="input-group">
213
  <div class="label">Strategy</div>
214
  <select id="sort">
 
217
  <option value="RATING">Top Ratings</option>
218
  </select>
219
  </div>
 
220
  <div class="input-group">
221
  <div class="label">
222
  <span>Star Rating Filter</span>
223
+ <div style="display:flex;gap:5px;">
224
  <button class="quick-btn" onclick="selectAllStars(true)">All</button>
225
  <button class="quick-btn" onclick="selectAllStars(false)">None</button>
226
  </div>
 
234
  </div>
235
  <div class="filter-chips" id="filterChips"></div>
236
  </div>
 
237
  <button class="btn-main" id="go" onclick="run()">
238
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
239
  START SCRAPING
240
  </button>
 
241
  <div class="input-group">
242
  <div class="label">Recent Sessions</div>
243
  <div id="recentList" style="display:flex;flex-direction:column;gap:8px;"></div>
 
252
  </div>
253
  <div id="results" class="hidden"></div>
254
  </div>
 
255
  <div id="loader" class="loader-overlay hidden">
256
  <div class="spinner"></div>
257
  <p style="color:var(--muted);font-size:14px" id="loaderMsg">Connecting to servers…</p>
258
  </div>
 
259
  <div id="siteView" class="hidden" style="height:100%">
260
  <div class="site-overlay">
261
  <h3>Web View Shielded</h3>
262
+ <p>Google Play Store blocks previewing inside other apps. Use the button below to view it in a new tab.</p>
263
  <button class="btn-main" style="width:auto;padding:12px 24px" onclick="openTarget()">
264
  Open on Google Play
265
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
 
269
  </div>
270
  </div>
271
 
272
+ <!-- Chat bubble -->
273
+ <div id="chat-dialer" onclick="toggleChat()">
274
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
275
+ </div>
276
+
277
+ <div id="chat-window">
278
+ <div class="chat-header">
279
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
280
+ <div class="chat-header-info">
281
+ <div class="chat-header-title">PlayPulse Intelligence</div>
282
+ <div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
283
+ </div>
284
+ <div class="chat-header-actions">
285
+ <button class="chat-clear-btn" onclick="clearChat()">Clear</button>
286
+ <div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
287
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
288
+ </div>
289
+ </div>
290
+ </div>
291
+ <div class="chat-messages" id="chat-messages">
292
+ <div class="msg-row bot">
293
+ <div class="message bot">👋 Hi! I'm PlayPulse Intelligence. Ask me anything about the loaded reviews — comparisons, issues, sentiment, keyword search, or say <em>"show in table"</em>.</div>
294
+ </div>
295
+ </div>
296
+ <div class="chat-suggestions" id="chat-sug">
297
+ <div class="sug-chip" onclick="fillChat('What are the main issues?')">Main issues</div>
298
+ <div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div>
299
+ <div class="sug-chip" onclick="fillChat('Show most helpful reviews')">Most helpful</div>
300
+ <div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1★ table</div>
301
+ </div>
302
+ <div class="chat-input-area">
303
+ <input type="text" id="chat-input" placeholder="Ask about the reviews…" onkeydown="if(event.key==='Enter') sendChatMessage()">
304
+ <button class="btn-send" onclick="sendChatMessage()">
305
+ <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
306
+ </button>
307
+ </div>
308
+ </div>
309
+
310
  <script>
311
  let mode = 'limit';
312
  let currentData = null;
 
317
  document.getElementById('btnLimit').classList.toggle('active', m==='limit');
318
  document.getElementById('manualCount').classList.toggle('hidden', m==='all');
319
  }
 
320
  function switchView(v, event) {
321
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
322
  event.target.classList.add('active');
323
  document.getElementById('dataView').classList.toggle('hidden', v!=='data');
324
  document.getElementById('siteView').classList.toggle('hidden', v!=='site');
325
  }
 
326
  function openTarget() {
327
  const url = document.getElementById('target').value;
328
  if (url.startsWith('http')) window.open(url,'_blank');
329
  else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`,'_blank');
330
  }
 
331
  function selectAllStars(checked) {
332
  document.querySelectorAll('.star-cb').forEach(cb => cb.checked = checked);
333
  updateChips();
 
341
  return [...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
342
  }
343
  document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
 
344
  function renderStars(score) {
345
+ let out='';
346
+ for(let i=1;i<=5;i++) out+=`<span style="font-size:13px;color:${i<=score?'var(--amber)':'var(--border)'}">★</span>`;
347
  return out;
348
  }
349
+ function fmtDate(iso) { if(!iso)return''; return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); }
350
+ function fmtNum(n) { if(!n)return null; if(n>=1000000)return(n/1000000).toFixed(1)+'M'; if(n>=1000)return(n/1000).toFixed(1)+'k'; return String(n); }
 
 
 
 
 
 
 
 
 
 
351
 
352
  async function run() {
353
+ const query=document.getElementById('target').value.trim();
354
+ if(!query)return;
355
+ const selectedStars=getSelectedStars();
356
+ if(!selectedStars.length){alert('Select at least one star rating.');return;}
 
357
  document.getElementById('results').innerHTML='';
358
  currentData=null;
359
  document.getElementById('welcome').classList.add('hidden');
 
361
  document.getElementById('loader').classList.remove('hidden');
362
  hideSuggestions();
363
  document.getElementById('go').disabled=true;
 
364
  const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
365
  let mi=0;
366
  document.getElementById('loaderMsg').textContent=msgs[0];
367
  const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2200);
 
368
  try {
369
+ const res=await fetch('/scrape',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({identifier:query,review_count_type:mode,review_count:parseInt(document.getElementById('manualCount').value)||200,sort_order:document.getElementById('sort').value,star_ratings:selectedStars.length===5?'all':selectedStars})});
 
 
 
 
 
 
 
 
 
 
370
  const data=await res.json();
371
+ if(!res.ok)throw new Error(data.error||'Scraping failed');
 
372
  currentData=data;
373
+ document.getElementById('results').classList.remove('hidden');
374
  render(data,selectedStars);
375
  save(data.app_info);
376
  } catch(e) {
377
+ document.getElementById('results').classList.remove('hidden');
378
+ document.getElementById('results').innerHTML=`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:14px;padding:24px 28px;"><div style="display:flex;align-items:center;gap:10px;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span style="font-weight:700;color:#ef4444;">Operation Failed</span></div><p style="color:var(--muted2);font-size:13px;margin-top:10px;">${e.message}</p></div>`;
 
 
 
 
 
 
 
379
  } finally {
380
  clearInterval(msgInt);
381
  document.getElementById('loader').classList.add('hidden');
 
383
  }
384
  }
385
 
386
+ function render(data,selectedStars,customReviews) {
387
+ const reviews=customReviews||data.reviews;
388
+ const info=data.app_info;
 
389
  const dist={1:0,2:0,3:0,4:0,5:0};
390
  reviews.forEach(r=>{if(r.score>=1&&r.score<=5)dist[r.score]++;});
391
  const total=reviews.length;
 
393
  const avgScore=total?(reviews.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2):'—';
394
  const totalLikes=reviews.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
395
  const filterLabel=selectedStars.length===5?'All Ratings':selectedStars.sort((a,b)=>b-a).map(s=>`${s}★`).join(', ');
396
+ const starDistHTML=[5,4,3,2,1].map(s=>{const pct=total?Math.round((dist[s]/total)*100):0;return`<div class="star-bar-row"><span style="color:var(--amber);width:12px;text-align:right">${s}</span><div class="star-bar-track"><div class="star-bar-fill" style="width:${pct}%"></div></div><span style="color:var(--muted);width:30px;text-align:right">${pct}%</span></div>`;}).join('');
 
 
 
 
 
 
 
 
 
 
 
397
  const reviewsHTML=reviews.map(r=>{
398
  const thumbsLabel=fmtNum(r.thumbsUpCount);
399
  const hasReply=r.replyContent&&r.replyContent.trim();
400
  const version=r.reviewCreatedVersion;
 
 
401
  const pills=[
402
+ thumbsLabel?`<span class="meta-pill thumbs"><svg viewBox="0 0 24 24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>${thumbsLabel} helpful</span>`:'',
403
+ version?`<span class="meta-pill version"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>v${version}</span>`:'',
404
+ hasReply?`<span class="meta-pill replied"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Dev replied</span>`:''
 
 
 
 
 
 
 
 
 
405
  ].filter(Boolean).join('');
406
+ const replyHTML=hasReply?`<div class="dev-reply"><div class="dev-reply-header"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Developer Response</div><div class="dev-reply-text">${r.replyContent}</div>${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}</div>`:'';
 
 
 
 
 
 
 
 
 
 
 
 
407
  const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
408
+ const avatarHTML=r.userImage?`<div class="user-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>`:`<div class="user-avatar">${initials}</div>`;
409
+ return`<div class="review-card"><div class="review-main"><div class="review-header">${avatarHTML}<div class="review-meta"><div class="review-username">${r.userName||'Anonymous'}</div><div class="review-date">${fmtDate(r.at)}</div></div><div class="review-stars">${renderStars(r.score)}</div></div><div class="review-text">${r.content||'<em style="color:var(--muted)">No review text</em>'}</div></div>${pills?`<div class="review-footer">${pills}</div>`:''} ${replyHTML}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  }).join('');
 
411
  document.getElementById('results').innerHTML=`
412
+ <div class="app-card"><img src="${info.icon}" alt="icon"><div style="flex:1"><h2 style="font-size:20px;font-weight:800;margin-bottom:3px">${info.title}</h2><div style="color:var(--accent);font-weight:700;font-size:11px;margin-bottom:10px">${info.appId}</div><div class="app-stats"><div class="stat-pill"><span class="stat-val" style="color:var(--amber)">${(info.score||0).toFixed(1)}</span><span class="stat-key">Store Avg</span></div><div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div><div class="stat-pill"><span class="stat-val" style="color:var(--green)">${repliedCount}</span><span class="stat-key">Replied</span></div><div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(totalLikes)||'0'}</span><span class="stat-key">Total Likes</span></div></div></div></div>
413
+ <div class="summary-bar"><div><div style="font-size:24px;font-weight:800;color:var(--amber)">${avgScore}</div><div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.5px;margin-top:2px">Avg Score</div></div><div class="star-dist">${starDistHTML}</div><div style="font-size:11px;color:var(--muted);padding:6px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--border)">Filter:<br><strong style="color:var(--accent)">${filterLabel}</strong></div></div>
414
+ <div style="display:flex;flex-direction:column;gap:10px">${reviewsHTML}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  }
416
 
417
  function downloadCSV() {
418
+ if(!currentData)return;
419
  const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
420
  const hdr=['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
421
+ const rows=currentData.reviews.map(r=>[esc(r.reviewId||''),esc(r.userName||''),r.score||0,esc((r.at||'').slice(0,10)),esc(r.content||''),r.thumbsUpCount||0,esc(r.reviewCreatedVersion||''),esc(r.replyContent||''),esc((r.repliedAt||'').slice(0,10))].join(','));
 
 
 
 
 
 
 
 
 
 
422
  const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
423
  const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
424
+ a.click();URL.revokeObjectURL(a.href);
425
  }
426
 
427
  function save(info) {
 
430
  localStorage.setItem('scrapes',JSON.stringify(list));
431
  loadRecent();
432
  }
 
433
  function loadRecent() {
434
  const list=JSON.parse(localStorage.getItem('scrapes')||'[]');
435
+ document.getElementById('recentList').innerHTML=list.map(x=>`<div onclick="document.getElementById('target').value='${x.appId}';run()" style="cursor:pointer;background:var(--bg);padding:10px;border-radius:8px;display:flex;gap:10px;align-items:center;border:1px solid var(--border);transition:.15s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'"><img src="${x.icon}" style="width:26px;height:26px;border-radius:5px" alt=""><span style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${x.title}</span><span style="font-size:10px;color:var(--muted)">${(x.score||0).toFixed(1)}★</span></div>`).join('');
 
 
 
 
 
436
  }
 
437
  loadRecent();
438
 
439
+ // ── Live search suggestions ────────────────────────────────────────────
440
+ let searchTimer=null;
441
+ const targetEl=document.getElementById('target');
442
+ const sugBox=document.getElementById('suggestionsBox');
443
+ function hideSuggestions(){sugBox.classList.add('hidden');sugBox.innerHTML='';}
444
+ targetEl.addEventListener('input',()=>{
445
+ clearTimeout(searchTimer);
446
+ const q=targetEl.value.trim();
447
+ if(q.startsWith('http')||/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(q)){hideSuggestions();return;}
448
+ if(q.length<2){hideSuggestions();return;}
449
+ sugBox.classList.remove('hidden');
450
+ sugBox.innerHTML='<div class="suggestion-loading"><div class="spinner-small"></div>Searching…</div>';
451
+ if(document.getElementById('results').classList.contains('hidden')){document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.remove('hidden');}
452
+ document.getElementById('results').innerHTML=`<div style="margin-bottom:20px;display:flex;align-items:center;gap:10px;"><span style="font-size:18px;font-weight:800;">Search Results: "${q}"</span></div><div class="search-results-grid" id="searchGrid"><div style="grid-column:1/-1;padding:40px;text-align:center;"><div class="spinner" style="margin:0 auto 15px;"></div><p style="color:var(--muted)">Searching the Play Store...</p></div></div>`;
453
+ searchTimer=setTimeout(async()=>{
454
+ try {
455
+ const res=await fetch('/search-suggestions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q})});
456
+ const data=await res.json();
457
+ if(!data.results||!data.results.length){sugBox.innerHTML='<div class="suggestion-loading">No results found</div>';return;}
458
+ sugBox.innerHTML=data.results.map(r=>`<div class="suggestion-item" onclick="selectSuggestion('${r.appId}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="suggestion-info"><div class="suggestion-title">${r.title}</div><div class="suggestion-sub">${r.developer} &bull; ${r.installs}</div></div><div class="suggestion-score">${r.score>0?r.score+' ★':''}</div></div>`).join('');
459
+ const gridHTML=data.results.map(r=>`<div class="search-app-card" onclick="selectSuggestion('${r.appId}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="search-app-info"><div><div class="search-app-title" title="${r.title}">${r.title}</div><div style="font-size:10px;color:var(--accent);font-family:monospace;margin-bottom:4px;">${r.appId}</div><div class="search-app-dev">${r.developer}</div></div><div class="search-app-meta"><span class="search-app-score">${r.score>0?'★ '+r.score:'N/A'}</span><span class="search-app-installs">${r.installs}</span><a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto;color:var(--muted);font-size:10px;text-decoration:none;">Store ↗</a></div></div></div>`).join('');
460
+ document.getElementById('searchGrid').innerHTML=gridHTML;
461
+ } catch(err){hideSuggestions();}
462
+ },400);
463
+ });
464
+ function selectSuggestion(appId,title){
465
+ const validId=appId&&appId!=='null'&&appId!=='None'&&!appId.includes('None');
466
+ targetEl.value=validId?`https://play.google.com/store/apps/details?id=${appId}`:title;
467
+ hideSuggestions();run();
468
  }
469
+ document.addEventListener('click',e=>{if(!targetEl.contains(e.target)&&!sugBox.contains(e.target))hideSuggestions();});
470
+ targetEl.addEventListener('keydown',e=>{if(e.key==='Escape')hideSuggestions();if(e.key==='Enter'){hideSuggestions();run();}});
471
 
472
+ // ══════════════════════════════════════════════════════════════════════
473
+ // CHAT — rich rendering, tables, markdown-lite, session memory
474
+ // ══════════════════════════════════════════════════════════════════════
475
+ const SESSION_ID=(()=>{let id=sessionStorage.getItem('pp_sid');if(!id){id='sess_'+Math.random().toString(36).slice(2);sessionStorage.setItem('pp_sid',id);}return id;})();
476
 
477
+ function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
478
+ function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();}
 
 
 
479
 
480
+ async function clearChat(){
481
+ document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared. Ask me anything about the loaded reviews!</div></div>`;
482
+ await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
483
+ }
484
 
485
+ async function sendChatMessage(){
486
+ const input=document.getElementById('chat-input');
487
+ const msg=input.value.trim();
488
+ if(!msg)return;
489
+ appendUserMsg(msg);
490
+ input.value='';
491
+ const container=document.getElementById('chat-messages');
492
+ const typing=document.createElement('div');
493
+ typing.className='typing-indicator';
494
+ typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
495
+ container.appendChild(typing);
496
+ container.scrollTop=container.scrollHeight;
497
+ try {
498
+ const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:(currentData&&currentData.reviews)?currentData.reviews:[]})});
499
+ const data=await res.json();
500
+ if(typing.parentNode)container.removeChild(typing);
501
+ if(data.error){appendBotMsg('⚠️ '+data.error,null);return;}
502
+ appendBotMsg(data.reply||'',data.table||null);
503
+ if(data.type==='filter'&&data.filters)applyChatFilters(data.filters);
504
+ } catch(e){
505
+ if(typing.parentNode)container.removeChild(typing);
506
+ appendBotMsg('Connection error. Is the server running?',null);
507
  }
508
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
 
510
+ function appendUserMsg(text){
511
+ const c=document.getElementById('chat-messages');
512
+ const row=document.createElement('div');row.className='msg-row user';
513
+ row.innerHTML=`<div class="message user">${escHtml(text)}</div>`;
514
+ c.appendChild(row);c.scrollTop=c.scrollHeight;
515
+ }
 
 
 
516
 
517
+ function appendBotMsg(text,table){
518
+ const c=document.getElementById('chat-messages');
519
+ const row=document.createElement('div');row.className='msg-row bot';
520
+ if(text&&text.trim()){
521
+ const b=document.createElement('div');b.className='message bot';
522
+ b.innerHTML=renderMD(text);row.appendChild(b);
523
+ }
524
+ if(table&&table.rows&&table.rows.length){row.appendChild(buildTable(table));}
525
+ c.appendChild(row);c.scrollTop=c.scrollHeight;
526
+ }
 
 
 
 
 
 
 
 
 
 
 
 
527
 
528
+ function renderMD(text){
529
+ const lines=text.split('\n');let html='',inList=false;
530
+ for(let raw of lines){
531
+ if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){
532
+ if(inList){html+='</div>';inList=false;}
533
+ html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;
534
  }
535
+ const nm=raw.match(/^(\d+)\.\s+(.+)/);
536
+ if(nm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${inlineFmt(nm[2])}</span></div>`;continue;}
537
+ const bm=raw.match(/^[•\-\*]\s+(.+)/);
538
+ if(bm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">•</span><span>${inlineFmt(bm[1])}</span></div>`;continue;}
539
+ if(inList&&raw.trim()===''){html+='</div>';inList=false;}
540
+ if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
541
+ }
542
+ if(inList)html+='</div>';
543
+ return html;
544
+ }
545
+ function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');}
546
+ function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
547
+
548
+ function buildTable(td){
549
+ const {title,columns,rows}=td;
550
+ const w=document.createElement('div');w.className='chat-table-wrap';
551
+ let h='';
552
+ if(title)h+=`<div class="chat-table-title">${escHtml(title)}</div>`;
553
+ h+='<table class="chat-table"><thead><tr>';
554
+ for(const c of columns)h+=`<th>${escHtml(c)}</th>`;
555
+ h+='</tr></thead><tbody>';
556
+ for(const row of rows){h+='<tr>';for(const c of columns){const v=row[c]!==undefined?row[c]:'';h+=`<td title="${escHtml(String(v))}">${escHtml(String(v))}</td>`;}h+='</tr>';}
557
+ h+='</tbody></table>';
558
+ w.innerHTML=h;return w;
559
  }
560
 
561
+ function applyChatFilters(raw){
562
+ if(!currentData)return;
563
+ try{
564
+ const f=typeof raw==='string'?JSON.parse(raw):raw;
565
+ let filtered=currentData.reviews;
566
+ if(f.stars&&f.stars.length)filtered=filtered.filter(r=>f.stars.includes(r.score));
567
+ if(f.app){const q=f.app.toLowerCase();filtered=filtered.filter(r=>(r.appTitle||'').toLowerCase().includes(q)||(r.appId||'').toLowerCase().includes(q));}
568
+ if(f.query){const q=f.query.toLowerCase();filtered=filtered.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));}
569
+ render(currentData,getSelectedStars(),filtered);
570
+ }catch(e){console.error('Filter error',e);}
571
+ }
572
  </script>
573
  </body>
574
  </html>
templates/landing.html CHANGED
@@ -6,239 +6,57 @@
6
  <title>PlayPulse | Intelligence Platform</title>
7
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
8
  <style>
9
- :root {
10
- --bg: #0b0e14;
11
- --surface: #151921;
12
- --border: rgba(255, 255, 255, 0.08);
13
- --accent: #3b82f6;
14
- --accent-gradient: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);
15
- --text: #f1f5f9;
16
- --muted: #94a3b8;
17
- }
18
-
19
- * { box-sizing: border-box; margin: 0; padding: 0; }
20
-
21
- /* Modern Scrollbar */
22
- ::-webkit-scrollbar { width: 6px; height: 6px; }
23
- ::-webkit-scrollbar-track { background: transparent; }
24
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
25
- ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
26
- * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
27
-
28
- body {
29
- font-family: 'Inter', sans-serif;
30
- background: var(--bg);
31
- color: var(--text);
32
- min-height: 100vh;
33
- display: flex;
34
- flex-direction: column;
35
- overflow-x: hidden;
36
- }
37
-
38
- .blob {
39
- position: fixed;
40
- width: 500px;
41
- height: 500px;
42
- background: var(--accent);
43
- filter: blur(120px);
44
- opacity: 0.1;
45
- z-index: -1;
46
- border-radius: 50%;
47
- }
48
- .blob-1 { top: -100px; right: -100px; }
49
- .blob-2 { bottom: -100px; left: -100px; background: #2dd4bf; }
50
-
51
- header {
52
- padding: 30px 5%;
53
- display: flex;
54
- justify-content: space-between;
55
- align-items: center;
56
- }
57
-
58
- .logo {
59
- font-family: 'Outfit', sans-serif;
60
- font-weight: 800;
61
- font-size: 24px;
62
- letter-spacing: -1px;
63
- color: var(--text);
64
- display: flex;
65
- align-items: center;
66
- gap: 10px;
67
- }
68
-
69
- .logo-icon {
70
- width: 32px;
71
- height: 32px;
72
- background: var(--accent-gradient);
73
- border-radius: 8px;
74
- display: flex;
75
- align-items: center;
76
- justify-content: center;
77
- }
78
-
79
- main {
80
- flex: 1;
81
- display: flex;
82
- flex-direction: column;
83
- align-items: center;
84
- justify-content: center;
85
- padding: 60px 5%;
86
- max-width: 1200px;
87
- margin: 0 auto;
88
- text-align: center;
89
- }
90
-
91
- .hero-tag {
92
- background: rgba(59, 130, 246, 0.1);
93
- border: 1px solid rgba(59, 130, 246, 0.2);
94
- color: var(--accent);
95
- padding: 6px 16px;
96
- border-radius: 100px;
97
- font-size: 13px;
98
- font-weight: 700;
99
- margin-bottom: 24px;
100
- text-transform: uppercase;
101
- letter-spacing: 1px;
102
- }
103
-
104
- h1 {
105
- font-family: 'Outfit', sans-serif;
106
- font-size: clamp(40px, 8vw, 72px);
107
- font-weight: 800;
108
- line-height: 1.1;
109
- margin-bottom: 20px;
110
- letter-spacing: -2px;
111
- }
112
-
113
- h1 span {
114
- background: var(--accent-gradient);
115
- -webkit-background-clip: text;
116
- background-clip: text;
117
- -webkit-text-fill-color: transparent;
118
- }
119
-
120
- .sub-hero {
121
- color: var(--muted);
122
- font-size: clamp(16px, 2vw, 20px);
123
- max-width: 600px;
124
- margin-bottom: 60px;
125
- line-height: 1.6;
126
- }
127
-
128
- .cards-container {
129
- display: grid;
130
- grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
131
- gap: 30px;
132
- width: 100%;
133
- }
134
-
135
- .card {
136
- background: var(--surface);
137
- border: 1px solid var(--border);
138
- padding: 40px;
139
- border-radius: 24px;
140
- text-align: left;
141
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
142
- cursor: pointer;
143
- position: relative;
144
- overflow: hidden;
145
- display: flex;
146
- flex-direction: column;
147
- height: 100%;
148
- }
149
-
150
- .card:hover {
151
- border-color: rgba(59, 130, 246, 0.5);
152
- transform: translateY(-8px);
153
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
154
- }
155
-
156
- .card.disabled {
157
- cursor: not-allowed;
158
- opacity: 0.8;
159
- }
160
- .card.disabled:hover { transform: none; border-color: var(--border); }
161
-
162
- .card-icon {
163
- width: 56px;
164
- height: 56px;
165
- background: rgba(255, 255, 255, 0.03);
166
- border: 1px solid var(--border);
167
- border-radius: 16px;
168
- display: flex;
169
- align-items: center;
170
- justify-content: center;
171
- margin-bottom: 24px;
172
- transition: 0.3s;
173
- }
174
-
175
- .card:hover .card-icon {
176
- background: var(--accent);
177
- color: white;
178
- border-color: var(--accent);
179
- }
180
-
181
- .card h2 {
182
- font-family: 'Outfit', sans-serif;
183
- font-size: 24px;
184
- margin-bottom: 12px;
185
- font-weight: 700;
186
- }
187
-
188
- .card p {
189
- color: var(--muted);
190
- line-height: 1.6;
191
- font-size: 15px;
192
- margin-bottom: 24px;
193
- flex: 1;
194
- }
195
-
196
- .badge {
197
- position: absolute;
198
- top: 20px;
199
- right: 20px;
200
- background: rgba(255, 255, 255, 0.05);
201
- border: 1px solid var(--border);
202
- padding: 4px 12px;
203
- border-radius: 100px;
204
- font-size: 11px;
205
- font-weight: 700;
206
- color: var(--muted);
207
- text-transform: uppercase;
208
- }
209
- .badge.active {
210
- background: var(--accent-dim);
211
- color: var(--accent);
212
- border-color: rgba(59, 130, 246, 0.2);
213
- }
214
-
215
- .btn {
216
- display: inline-flex;
217
- align-items: center;
218
- gap: 8px;
219
- font-weight: 700;
220
- font-size: 14px;
221
- color: var(--text);
222
- transition: 0.3s;
223
- }
224
-
225
- .card:hover .btn {
226
- color: var(--accent);
227
- }
228
-
229
- .card.disabled .btn { color: var(--muted); }
230
-
231
- footer {
232
- padding: 40px;
233
- text-align: center;
234
- color: var(--muted);
235
- font-size: 13px;
236
- }
237
-
238
- @media (max-width: 768px) {
239
- h1 { font-size: 48px; }
240
- .cards-container { grid-template-columns: 1fr; }
241
- }
242
  </style>
243
  </head>
244
  <body>
@@ -257,41 +75,104 @@
257
  <main>
258
  <div class="hero-tag">Next-Gen Intelligence</div>
259
  <h1>Extract Insights from <span>Global App Data</span></h1>
260
- <p class="sub-hero">The most powerful tool for analyzing app reviews, sentiment, and developer responses in real-time.</p>
261
 
262
  <div class="cards-container">
263
- <!-- Single Search Card -->
264
  <div class="card" onclick="location.href='/scraper'">
265
  <div class="badge active">Live Now</div>
266
  <div class="card-icon">
267
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
268
  </div>
269
  <h2>Single App Explorer</h2>
270
- <p>Deep-dive into any Play Store app. Extract hundreds of reviews, analyze ratings, and export clean data instantly.</p>
271
- <div class="btn">
272
- Explore Now
273
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
274
- </div>
275
  </div>
276
-
277
- <!-- Batch Intelligence Card -->
278
  <div class="card" onclick="location.href='/batch'">
279
  <div class="badge active">New Mode</div>
280
  <div class="card-icon">
281
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
282
  </div>
283
  <h2>Batch Intelligence</h2>
284
- <p>Compare multiple apps side-by-side. Track competitor updates and aggregate sentiment across entire categories.</p>
285
- <div class="btn">
286
- Start Analysis
287
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
288
- </div>
289
  </div>
290
  </div>
291
  </main>
292
 
293
- <footer>
294
- &copy; 2026 PlayPulse Intelligence. Powered by Google Play Scraper Engine.
295
- </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  </body>
297
- </html>
 
6
  <title>PlayPulse | Intelligence Platform</title>
7
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
8
  <style>
9
+ :root { --bg:#0b0e14; --surface:#151921; --surface2:#1c2333; --border:rgba(255,255,255,0.08); --accent:#3b82f6; --accent-gradient:linear-gradient(135deg,#3b82f6 0%,#2dd4bf 100%); --text:#f1f5f9; --muted:#94a3b8; }
10
+ *{box-sizing:border-box;margin:0;padding:0;}
11
+ ::-webkit-scrollbar{width:6px;} ::-webkit-scrollbar-track{background:transparent;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:10px;}
12
+ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;overflow-x:hidden;}
13
+ .blob{position:fixed;width:500px;height:500px;background:var(--accent);filter:blur(120px);opacity:0.1;z-index:-1;border-radius:50%;}
14
+ .blob-1{top:-100px;right:-100px;} .blob-2{bottom:-100px;left:-100px;background:#2dd4bf;}
15
+ header{padding:30px 5%;display:flex;justify-content:space-between;align-items:center;}
16
+ .logo{font-family:'Outfit',sans-serif;font-weight:800;font-size:24px;letter-spacing:-1px;color:var(--text);display:flex;align-items:center;gap:10px;}
17
+ .logo-icon{width:32px;height:32px;background:var(--accent-gradient);border-radius:8px;display:flex;align-items:center;justify-content:center;}
18
+ main{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 5%;max-width:1200px;margin:0 auto;text-align:center;width:100%;}
19
+ .hero-tag{background:rgba(59,130,246,0.1);border:1px solid rgba(59,130,246,0.2);color:var(--accent);padding:6px 16px;border-radius:100px;font-size:13px;font-weight:700;margin-bottom:24px;text-transform:uppercase;letter-spacing:1px;}
20
+ h1{font-family:'Outfit',sans-serif;font-size:clamp(40px,8vw,72px);font-weight:800;line-height:1.1;margin-bottom:20px;letter-spacing:-2px;}
21
+ h1 span{background:var(--accent-gradient);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
22
+ .sub-hero{color:var(--muted);font-size:clamp(16px,2vw,20px);max-width:600px;margin-bottom:60px;line-height:1.6;}
23
+ .cards-container{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:30px;width:100%;}
24
+ .card{background:var(--surface);border:1px solid var(--border);padding:40px;border-radius:24px;text-align:left;transition:all 0.3s cubic-bezier(0.4,0,0.2,1);cursor:pointer;position:relative;overflow:hidden;display:flex;flex-direction:column;height:100%;}
25
+ .card:hover{border-color:rgba(59,130,246,0.5);transform:translateY(-8px);box-shadow:0 20px 40px rgba(0,0,0,0.4);}
26
+ .card-icon{width:56px;height:56px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:16px;display:flex;align-items:center;justify-content:center;margin-bottom:24px;transition:0.3s;}
27
+ .card:hover .card-icon{background:var(--accent);color:white;border-color:var(--accent);}
28
+ .card h2{font-family:'Outfit',sans-serif;font-size:24px;margin-bottom:12px;font-weight:700;}
29
+ .card p{color:var(--muted);line-height:1.6;font-size:15px;margin-bottom:24px;flex:1;}
30
+ .badge{position:absolute;top:20px;right:20px;background:rgba(255,255,255,0.05);border:1px solid var(--border);padding:4px 12px;border-radius:100px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;}
31
+ .badge.active{background:rgba(59,130,246,0.1);color:var(--accent);border-color:rgba(59,130,246,0.2);}
32
+ .btn{display:inline-flex;align-items:center;gap:8px;font-weight:700;font-size:14px;color:var(--text);transition:0.3s;}
33
+ .card:hover .btn{color:var(--accent);}
34
+ footer{padding:40px;text-align:center;color:var(--muted);font-size:13px;}
35
+ @media(max-width:768px){h1{font-size:48px;}.cards-container{grid-template-columns:1fr;}}
36
+
37
+ /* ── Chat styles ── */
38
+ #chat-dialer{position:fixed;bottom:24px;right:24px;width:56px;height:56px;background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 32px rgba(59,130,246,0.4);cursor:pointer;z-index:1000;transition:0.3s cubic-bezier(0.175,0.885,0.32,1.275);border:2px solid rgba(255,255,255,0.1);}
39
+ #chat-dialer:hover{transform:scale(1.1) rotate(5deg);box-shadow:0 12px 40px rgba(59,130,246,0.6);}
40
+ #chat-dialer svg{width:24px;height:24px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;}
41
+ #chat-window{position:fixed;bottom:90px;right:24px;width:420px;height:560px;background:var(--surface);border:1px solid rgba(255,255,255,0.1);border-radius:20px;display:flex;flex-direction:column;box-shadow:0 20px 50px rgba(0,0,0,0.5);z-index:1001;overflow:hidden;transform:translateY(20px) scale(0.95);opacity:0;pointer-events:none;transition:0.3s cubic-bezier(0.4,0,0.2,1);backdrop-filter:blur(20px);}
42
+ #chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;}
43
+ .chat-header{padding:14px 18px;background:var(--accent);color:white;display:flex;align-items:center;gap:12px;flex-shrink:0;}
44
+ .chat-header-info{flex:1;} .chat-header-title{font-weight:800;font-size:15px;} .chat-header-status{font-size:10px;opacity:0.8;display:flex;align-items:center;gap:4px;} .status-dot{width:6px;height:6px;background:#22c55e;border-radius:50%;}
45
+ .chat-header-actions{display:flex;gap:8px;align-items:center;}
46
+ .chat-clear-btn{background:rgba(255,255,255,0.15);border:none;color:white;font-size:11px;padding:4px 10px;border-radius:8px;cursor:pointer;transition:0.2s;} .chat-clear-btn:hover{background:rgba(255,255,255,0.25);}
47
+ .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background-image:radial-gradient(rgba(255,255,255,0.05) 1px,transparent 1px);background-size:20px 20px;}
48
+ .msg-row{display:flex;flex-direction:column;gap:4px;} .msg-row.user{align-items:flex-end;} .msg-row.bot{align-items:flex-start;}
49
+ .message{max-width:88%;padding:11px 15px;border-radius:16px;font-size:13px;line-height:1.6;}
50
+ .message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;}
51
+ .message.bot{background:var(--surface2);color:var(--text);border:1px solid rgba(255,255,255,0.08);border-bottom-left-radius:4px;white-space:pre-wrap;word-break:break-word;}
52
+ .msg-section{margin-top:10px;font-weight:700;font-size:11px;color:var(--accent);letter-spacing:0.05em;text-transform:uppercase;}
53
+ .msg-item{display:flex;gap:8px;margin-top:5px;} .msg-item-num{font-weight:700;color:var(--accent);min-width:16px;} .msg-bullet{color:var(--accent);min-width:14px;}
54
+ .typing-indicator{display:flex;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid rgba(255,255,255,0.08);border-radius:16px;width:fit-content;}
55
+ .dot{width:6px;height:6px;background:#64748b;border-radius:50%;animation:bounce 1.4s infinite;} .dot:nth-child(2){animation-delay:0.2s;} .dot:nth-child(3){animation-delay:0.4s;}
56
+ @keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
57
+ .chat-input-area{padding:14px 16px;background:var(--surface);border-top:1px solid rgba(255,255,255,0.06);display:flex;gap:10px;flex-shrink:0;}
58
+ #chat-input{flex:1;background:var(--bg);border:1px solid rgba(255,255,255,0.08);color:white;padding:10px 14px;border-radius:12px;font-size:13px;outline:none;} #chat-input:focus{border-color:var(--accent);}
59
+ .btn-send{width:40px;height:40px;background:var(--accent);color:white;border:none;border-radius:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:0.2s;flex-shrink:0;} .btn-send:hover{transform:scale(1.05);} .btn-send svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.5;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  </style>
61
  </head>
62
  <body>
 
75
  <main>
76
  <div class="hero-tag">Next-Gen Intelligence</div>
77
  <h1>Extract Insights from <span>Global App Data</span></h1>
78
+ <p class="sub-hero">The most powerful tool for analyzing app reviews, sentiment, and developer responses in real-time. Powered by AI chat.</p>
79
 
80
  <div class="cards-container">
 
81
  <div class="card" onclick="location.href='/scraper'">
82
  <div class="badge active">Live Now</div>
83
  <div class="card-icon">
84
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
85
  </div>
86
  <h2>Single App Explorer</h2>
87
+ <p>Deep-dive into any Play Store app. Extract hundreds of reviews, analyze ratings, and chat with AI to get instant insights.</p>
88
+ <div class="btn">Explore Now <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
 
 
 
89
  </div>
 
 
90
  <div class="card" onclick="location.href='/batch'">
91
  <div class="badge active">New Mode</div>
92
  <div class="card-icon">
93
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
94
  </div>
95
  <h2>Batch Intelligence</h2>
96
+ <p>Compare multiple apps side-by-side. Track competitor updates and aggregate sentiment across entire game categories.</p>
97
+ <div class="btn">Start Analysis <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg></div>
 
 
 
98
  </div>
99
  </div>
100
  </main>
101
 
102
+ <footer>&copy; 2026 PlayPulse Intelligence. Powered by Google Play Scraper Engine.</footer>
103
+
104
+ <!-- Chat bubble -->
105
+ <div id="chat-dialer" onclick="toggleChat()">
106
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
107
+ </div>
108
+
109
+ <div id="chat-window">
110
+ <div class="chat-header">
111
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
112
+ <div class="chat-header-info">
113
+ <div class="chat-header-title">PlayPulse Intelligence</div>
114
+ <div class="chat-header-status"><span class="status-dot"></span> Agent Online</div>
115
+ </div>
116
+ <div class="chat-header-actions">
117
+ <button class="chat-clear-btn" onclick="clearChat()">Clear</button>
118
+ <div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()">
119
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ <div class="chat-messages" id="chat-messages">
124
+ <div class="msg-row bot">
125
+ <div class="message bot">👋 Welcome to PlayPulse! I can help you understand what tools are available, or answer general app-store questions. Head to <strong>Single Explorer</strong> or <strong>Batch Intelligence</strong> to start analyzing reviews.</div>
126
+ </div>
127
+ </div>
128
+ <div class="chat-input-area">
129
+ <input type="text" id="chat-input" placeholder="Ask a question…" onkeydown="if(event.key==='Enter') sendChatMessage()">
130
+ <button class="btn-send" onclick="sendChatMessage()">
131
+ <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
132
+ </button>
133
+ </div>
134
+ </div>
135
+
136
+ <script>
137
+ const SESSION_ID=(()=>{let id=sessionStorage.getItem('pp_sid');if(!id){id='sess_'+Math.random().toString(36).slice(2);sessionStorage.setItem('pp_sid',id);}return id;})();
138
+
139
+ function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
140
+
141
+ async function clearChat(){
142
+ document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared!</div></div>`;
143
+ await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});
144
+ }
145
+
146
+ async function sendChatMessage(){
147
+ const input=document.getElementById('chat-input');const msg=input.value.trim();if(!msg)return;
148
+ appendUserMsg(msg);input.value='';
149
+ const container=document.getElementById('chat-messages');
150
+ const typing=document.createElement('div');typing.className='typing-indicator';typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
151
+ container.appendChild(typing);container.scrollTop=container.scrollHeight;
152
+ try{
153
+ const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:[]})});
154
+ const data=await res.json();
155
+ if(typing.parentNode)container.removeChild(typing);
156
+ appendBotMsg(data.reply||data.error||'Something went wrong.',null);
157
+ }catch(e){if(typing.parentNode)container.removeChild(typing);appendBotMsg('Connection error.',null);}
158
+ }
159
+
160
+ function appendUserMsg(text){const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row user';row.innerHTML=`<div class="message user">${escHtml(text)}</div>`;c.appendChild(row);c.scrollTop=c.scrollHeight;}
161
+ function appendBotMsg(text,table){const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row bot';if(text&&text.trim()){const b=document.createElement('div');b.className='message bot';b.innerHTML=renderMD(text);row.appendChild(b);}c.appendChild(row);c.scrollTop=c.scrollHeight;}
162
+
163
+ function renderMD(text){
164
+ const lines=text.split('\n');let html='',inList=false;
165
+ for(let raw of lines){
166
+ if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;}
167
+ const nm=raw.match(/^(\d+)\.\s+(.+)/);if(nm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${inlineFmt(nm[2])}</span></div>`;continue;}
168
+ const bm=raw.match(/^[•\-\*]\s+(.+)/);if(bm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">•</span><span>${inlineFmt(bm[1])}</span></div>`;continue;}
169
+ if(inList&&raw.trim()===''){html+='</div>';inList=false;}
170
+ if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;}
171
+ }
172
+ if(inList)html+='</div>';return html;
173
+ }
174
+ function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em>$1</em>');}
175
+ function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
176
+ </script>
177
  </body>
178
+ </html>
utils/agents.py ADDED
@@ -0,0 +1,1553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ╔══════════════════════════════════════════════════════════════════════════════╗
4
+ ║ REVIEW INTELLIGENCE AGENT v4 — LangGraph + LangChain ║
5
+ ║ BERT sentiment · merged tools · app-aware · Pinecone native embed ║
6
+ ║ Code-agent tool · scope guard · LLM fallback chain ║
7
+ ╚══════════════════════════════════════════════════════════════════════════════╝
8
+
9
+ CHANGES FROM v3:
10
+ ① NLP — DistilBERT (transformers) replaces LLM batched sentiment.
11
+ Zero token cost. Runs locally. Falls back to rating-heuristic.
12
+ ② Tools — get_top_negative / get_top_positive merged into one tool
13
+ get_reviews_by_rating(min_stars, max_stars, n, app_name?).
14
+ All tools accept optional app_name to handle multi-app CSVs.
15
+ ③ Pinecone — uses pc.create_index_for_model() + idx.upsert_records()
16
+ (Pinecone integrated embedding, no OpenAI dependency).
17
+ Query uses pc.inference.embed() for the query vector.
18
+ ④ Code-Agent — new @tool run_pandas_code(code) that POSTs to a
19
+ deployed HuggingFace Space (docker) for heavy pandas/stats.
20
+ ⑤ Scope guard — planner tracks iteration count; terminates cleanly
21
+ after MAX_PLANNER_ITERATIONS to avoid infinite loops.
22
+
23
+ Install:
24
+ pip install langgraph langchain langchain-core langchain-openai \
25
+ langchain-groq pinecone pandas transformers torch \
26
+ rich python-dotenv requests
27
+
28
+ Usage:
29
+ python review_agent_v4.py --csv reviews.csv
30
+ python review_agent_v4.py --csv reviews.csv \\
31
+ --query "Which action game has the most ad complaints?"
32
+ python review_agent_v4.py --csv reviews.csv --use-pinecone \\
33
+ --query "Show crash issues for com.JindoBlu app"
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import hashlib
40
+ import json
41
+ import logging
42
+ import operator
43
+ import os
44
+ import re
45
+ import sys
46
+ import time
47
+ import textwrap
48
+ import uuid
49
+ from datetime import datetime
50
+ from pathlib import Path
51
+ from typing import Annotated, Any, Optional, TypedDict
52
+
53
+ import pandas as pd
54
+ import requests
55
+
56
+ # ── LangGraph / LangChain ──────────────────────────────────────────────────
57
+ from langgraph.graph import StateGraph, START, END
58
+ from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
59
+ from langchain_core.prompts import ChatPromptTemplate
60
+ from langchain_core.tools import tool
61
+
62
+ try:
63
+ from langchain_groq import ChatGroq
64
+ HAS_GROQ = True
65
+ except ImportError:
66
+ HAS_GROQ = False
67
+
68
+ try:
69
+ from langchain_openai import ChatOpenAI
70
+ HAS_OPENAI_PKG = True
71
+ except ImportError:
72
+ HAS_OPENAI_PKG = False
73
+
74
+ # ── Pinecone (native SDK only — no langchain-pinecone needed) ──────────────
75
+ try:
76
+ from pinecone import Pinecone as PineconeClient, ServerlessSpec
77
+ from pinecone import CloudProvider, AwsRegion, EmbedModel, IndexEmbed
78
+ HAS_PINECONE = True
79
+ except ImportError:
80
+ HAS_PINECONE = False
81
+
82
+ # ── BERT sentiment (local, zero token cost) ────────────────────────────────
83
+ try:
84
+ from transformers import pipeline as hf_pipeline
85
+ HAS_TRANSFORMERS = True
86
+ except ImportError:
87
+ HAS_TRANSFORMERS = False
88
+
89
+ # ── Rich terminal ──────────────────────────────────────────────────────────
90
+ try:
91
+ from rich.console import Console
92
+ from rich.panel import Panel
93
+ from rich.table import Table
94
+ from rich import box
95
+ RICH = True
96
+ console = Console()
97
+ except ImportError:
98
+ RICH = False
99
+ console = None # type: ignore
100
+
101
+ logging.basicConfig(level=logging.WARNING)
102
+
103
+ # ══════════════════════════════════════════════════════════════════════════════
104
+ # CONSTANTS
105
+ # ══════════════════════════════════════════════════════════════════════════════
106
+
107
+ # LLM providers
108
+ GROQ_MODEL = "llama-3.3-70b-versatile"
109
+ OPENROUTER_URL = "https://openrouter.ai/api/v1"
110
+ OPENROUTER_MODEL = "meta-llama/llama-3.3-70b-instruct"
111
+ NVIDIA_URL = "https://integrate.api.nvidia.com/v1"
112
+ NVIDIA_MODEL = "meta/llama-3.3-70b-instruct"
113
+
114
+ # BERT model — lightweight distilled, ~67M params, runs on CPU
115
+ BERT_SENTIMENT_MODEL = "distilbert-base-uncased-finetuned-sst-2-english"
116
+ BERT_BATCH_SIZE = 64 # reviews per inference batch
117
+ MAX_BERT_TEXT_LEN = 512 # chars; BERT max tokens ≈ 512 subwords
118
+
119
+ # Pipeline limits
120
+ SAMPLE_ROWS_FOR_SCHEMA = 5
121
+ MAX_REVIEWS_NLP = 5_000 # BERT is fast; can handle more
122
+ MAX_CLUSTERS = 10
123
+ MAX_PLANNER_ITERATIONS = 4 # ④ scope guard: stop after N iterations
124
+ ANOMALY_SIGMA = 2.0
125
+
126
+ # Pinecone integrated embedding
127
+ PINECONE_EMBED_MODEL = "multilingual-e5-large" # free on Pinecone starter
128
+ PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX", "review-agent-v4")
129
+ PINECONE_NAMESPACE = "reviews"
130
+ PINECONE_TOP_K = 6
131
+ PINECONE_UPSERT_BATCH = 96 # upsert_records batch size
132
+
133
+ # Code-agent HuggingFace Space endpoint
134
+ # Deploy the companion space (see code_agent_space/ folder) and set this env var
135
+ HF_CODE_AGENT_URL = os.getenv(
136
+ "HF_CODE_AGENT_URL",
137
+ "https://YOUR-HF-USERNAME-review-code-agent.hf.space/run"
138
+ )
139
+
140
+
141
+ # ══════════════════════════════════════════════════════════════════════════════
142
+ # ① LLM PROVIDER CHAIN — Groq → OpenRouter → NVIDIA NIMs
143
+ # ══════════════════════════════════════════════════════════════════════════════
144
+
145
+ def _make_groq():
146
+ key = os.getenv("GROQ_API_KEY", "")
147
+ if not key or not HAS_GROQ:
148
+ return None
149
+ return ChatGroq(model=GROQ_MODEL, temperature=0, max_retries=1, api_key=key)
150
+
151
+
152
+ def _make_openrouter():
153
+ key = os.getenv("OPENROUTER_API_KEY", "")
154
+ if not key or not HAS_OPENAI_PKG:
155
+ return None
156
+ return ChatOpenAI(
157
+ model=OPENROUTER_MODEL, temperature=0, max_retries=1, api_key=key,
158
+ base_url=OPENROUTER_URL,
159
+ default_headers={"HTTP-Referer": "review-agent-v4"},
160
+ )
161
+
162
+
163
+ def _make_nvidia():
164
+ key = os.getenv("NVIDIA_API_KEY", "")
165
+ if not key or not HAS_OPENAI_PKG:
166
+ return None
167
+ return ChatOpenAI(
168
+ model=NVIDIA_MODEL, temperature=0, max_retries=1,
169
+ api_key=key, base_url=NVIDIA_URL,
170
+ )
171
+
172
+
173
+ def build_llm():
174
+ """Return LLM chain with .with_fallbacks() or None."""
175
+ providers = [p for p in [_make_groq(), _make_openrouter(), _make_nvidia()]
176
+ if p is not None]
177
+ if not providers:
178
+ return None
179
+ primary = providers[0]
180
+ fallbacks = providers[1:]
181
+ return primary.with_fallbacks(fallbacks) if fallbacks else primary
182
+
183
+
184
+ LLM = build_llm()
185
+ _PROVIDER_TAG = ("Groq→OpenRouter→NVIDIA" if LLM else "NO LLM — heuristic only")
186
+
187
+
188
+ # ══════════════════════════════════════════════════════════════════════════════
189
+ # ① BERT SENTIMENT ANALYSER (local, zero cost)
190
+ # ══════════════════════════════════════════════════════════════════════════════
191
+
192
+ _bert_pipe = None # lazy-loaded
193
+
194
+ def _load_bert():
195
+ global _bert_pipe
196
+ if _bert_pipe is None and HAS_TRANSFORMERS:
197
+ _log("Loading DistilBERT sentiment model (first run may download ~270 MB)…", "info")
198
+ _bert_pipe = hf_pipeline(
199
+ "sentiment-analysis",
200
+ model=BERT_SENTIMENT_MODEL,
201
+ truncation=True,
202
+ max_length=512,
203
+ device=-1, # CPU; set device=0 for GPU
204
+ batch_size=BERT_BATCH_SIZE,
205
+ )
206
+ _log("DistilBERT loaded ✓", "ok")
207
+ return _bert_pipe
208
+
209
+
210
+ def bert_sentiment(texts: list[str]) -> dict[str, Any]:
211
+ """
212
+ Run DistilBERT sentiment on a list of texts.
213
+ Returns counts + themes extracted purely from rating distribution (no LLM).
214
+ Falls back to None if transformers not installed.
215
+ """
216
+ pipe = _load_bert()
217
+ if pipe is None:
218
+ return {}
219
+
220
+ # Truncate texts for BERT
221
+ clean = [t[:MAX_BERT_TEXT_LEN] for t in texts if t.strip()]
222
+ if not clean:
223
+ return {}
224
+
225
+ pos = neg = 0
226
+ for i in range(0, len(clean), BERT_BATCH_SIZE):
227
+ batch = clean[i:i + BERT_BATCH_SIZE]
228
+ try:
229
+ results = pipe(batch)
230
+ for r in results:
231
+ if r["label"] == "POSITIVE":
232
+ pos += 1
233
+ else:
234
+ neg += 1
235
+ except Exception as e:
236
+ _log(f"BERT batch error: {e}", "warn")
237
+
238
+ total = max(1, pos + neg)
239
+ neu = 0 # DistilBERT SST-2 is binary; neutral is inferred below
240
+ pct_pos = round(pos / total * 100, 1)
241
+ pct_neg = round(neg / total * 100, 1)
242
+
243
+ # Heuristic tone
244
+ if pct_pos >= 60: tone = "Positive"
245
+ elif pct_neg >= 50: tone = "Negative"
246
+ elif abs(pct_pos - pct_neg) < 20: tone = "Polarised"
247
+ else: tone = "Mixed"
248
+
249
+ return {
250
+ "pct_positive": pct_pos,
251
+ "pct_neutral": round(100 - pct_pos - pct_neg, 1),
252
+ "pct_negative": pct_neg,
253
+ "overall_tone": tone,
254
+ "method": "DistilBERT (local)",
255
+ "themes": [], # populated by LLM cluster node
256
+ "phrases": [],
257
+ }
258
+
259
+
260
+ # ══════════════════════════════════════════════════════════════════════════════
261
+ # GRAPH STATE
262
+ # ══════════════════════════════════════════════════════════════════════════════
263
+
264
+ class ReviewState(TypedDict, total=False):
265
+ # Inputs
266
+ filepath: str
267
+ user_query: str
268
+ use_pinecone: bool
269
+
270
+ # Data
271
+ raw_df: Any # pd.DataFrame
272
+ total_rows: int
273
+ columns: list[str]
274
+ schema: dict[str, Optional[str]]
275
+ schema_confidence: str
276
+ detected_apps: list[str] # unique app names found in data
277
+
278
+ # Analysis
279
+ stats: dict[str, Any]
280
+ app_breakdown: list[dict]
281
+ sentiment: dict[str, Any]
282
+ clusters: list[dict]
283
+ sample_texts: list[str]
284
+
285
+ # Planner
286
+ tool_results: list[dict]
287
+ planner_notes: str
288
+ planner_iter: int # ④ scope guard counter
289
+
290
+ # RAG
291
+ rag_context: str
292
+
293
+ # Report
294
+ report: dict[str, Any]
295
+
296
+ # Message trace (reducer = append)
297
+ messages: Annotated[list[BaseMessage], operator.add]
298
+
299
+ # Meta
300
+ errors: list[str]
301
+ run_id: str
302
+
303
+
304
+ def _empty_state(filepath: str, query: str, use_pinecone: bool) -> ReviewState:
305
+ return ReviewState(
306
+ filepath=filepath, user_query=query, use_pinecone=use_pinecone,
307
+ raw_df=None, total_rows=0, columns=[], schema={},
308
+ schema_confidence="", detected_apps=[],
309
+ stats={}, app_breakdown=[], sentiment={}, clusters=[],
310
+ sample_texts=[], tool_results=[], planner_notes="",
311
+ planner_iter=0, rag_context="", report={},
312
+ messages=[], errors=[], run_id=f"run_{int(time.time())}",
313
+ )
314
+
315
+
316
+ # ══════════════════════════════════════════════════════════════════════════════
317
+ # TOOL CLOSURE STATE (set before planner runs)
318
+ # ══════════════════════════════════════════════════════════════════════════════
319
+
320
+ _ACTIVE: dict[str, Any] = {}
321
+
322
+ def _df() -> Optional[pd.DataFrame]: return _ACTIVE.get("raw_df")
323
+ def _schema() -> dict: return _ACTIVE.get("schema", {})
324
+ def _apps() -> list[str]: return _ACTIVE.get("detected_apps", [])
325
+
326
+
327
+ # ══════════════════════════════════════════════════════════════════════════════
328
+ # ② TOOLS — merged, app-aware
329
+ # ══════════════════════════════════════════════════════════════════════════════
330
+
331
+ def _base_df(app_name: Optional[str] = None) -> Optional[pd.DataFrame]:
332
+ """Return DataFrame, optionally filtered to a specific app."""
333
+ df = _df()
334
+ if df is None:
335
+ return None
336
+ ac = _schema().get("app")
337
+ if app_name and ac:
338
+ mask = df[ac].astype(str).str.lower().str.contains(
339
+ re.escape(app_name.lower()), na=False)
340
+ return df[mask].copy()
341
+ return df.copy()
342
+
343
+
344
+ @tool
345
+ def get_reviews_by_rating(
346
+ min_stars: int = 1,
347
+ max_stars: int = 5,
348
+ n: int = 15,
349
+ app_name: Optional[str] = None,
350
+ ) -> str:
351
+ """
352
+ Retrieve reviews filtered by star rating range [min_stars, max_stars].
353
+ Replaces separate positive/negative tools.
354
+
355
+ Examples:
356
+ get_reviews_by_rating(1, 2) — all 1-2 star reviews
357
+ get_reviews_by_rating(4, 5, n=20) — top positive reviews
358
+ get_reviews_by_rating(1, 1, app_name="Challenge") — 1-star for one app
359
+
360
+ Use this for any star-based filtering. It understands that ratings 1-2
361
+ are negative, 3 is neutral, and 4-5 are positive.
362
+ """
363
+ df = _base_df(app_name)
364
+ s = _schema()
365
+ rc = s.get("rating"); tc = s.get("text")
366
+ if df is None or not rc or not tc:
367
+ return json.dumps({"error": "No rating or text column."})
368
+
369
+ df["__r"] = pd.to_numeric(df[rc], errors="coerce")
370
+ mask = (df["__r"] >= min_stars) & (df["__r"] <= max_stars)
371
+ filtered = df[mask].dropna(subset=["__r"])
372
+
373
+ cols = {tc: "text", rc: "rating"}
374
+ ac = s.get("app")
375
+ if ac: cols[ac] = "app"
376
+ uc = s.get("user")
377
+ if uc: cols[uc] = "user"
378
+
379
+ rows = (filtered[list(cols.keys())]
380
+ .rename(columns=cols)
381
+ .head(n)
382
+ .to_dict("records"))
383
+ return json.dumps({
384
+ "filter": f"{min_stars}–{max_stars} stars",
385
+ "app_filter": app_name or "all",
386
+ "count_matched": int(mask.sum()),
387
+ "returned": len(rows),
388
+ "reviews": rows,
389
+ }, ensure_ascii=False)
390
+
391
+
392
+ @tool
393
+ def get_most_helpful_reviews(
394
+ n: int = 10,
395
+ app_name: Optional[str] = None,
396
+ ) -> str:
397
+ """
398
+ Return the reviews with the most helpful/thumbs-up votes.
399
+ Optionally filtered to a specific app. Useful for finding highly-validated complaints.
400
+ """
401
+ df = _base_df(app_name)
402
+ s = _schema()
403
+ hc = s.get("helpful"); tc = s.get("text"); rc = s.get("rating")
404
+ if df is None or not hc or not tc:
405
+ return json.dumps({"error": "No helpful column found."})
406
+
407
+ df["__h"] = pd.to_numeric(df[hc], errors="coerce").fillna(0)
408
+ cols = {tc: "text", hc: "helpful_votes"}
409
+ if rc: cols[rc] = "rating"
410
+ ac = s.get("app")
411
+ if ac: cols[ac] = "app"
412
+
413
+ rows = (df.nlargest(n, "__h")[list(cols.keys())]
414
+ .rename(columns=cols)
415
+ .to_dict("records"))
416
+ return json.dumps({
417
+ "app_filter": app_name or "all",
418
+ "returned": len(rows),
419
+ "reviews": rows,
420
+ }, ensure_ascii=False)
421
+
422
+
423
+ @tool
424
+ def get_rating_timeseries(app_name: Optional[str] = None) -> str:
425
+ """
426
+ Return daily average rating over time [{date, avg_rating, count}].
427
+ Pass app_name to see trends for a specific app only.
428
+ Useful for spotting when ratings dropped after an update.
429
+ """
430
+ df = _base_df(app_name)
431
+ s = _schema()
432
+ dc = s.get("date"); rc = s.get("rating")
433
+ if df is None or not dc or not rc:
434
+ return json.dumps({"error": "No date or rating column."})
435
+
436
+ df["__d"] = pd.to_datetime(df[dc], errors="coerce")
437
+ df["__r"] = pd.to_numeric(df[rc], errors="coerce")
438
+ df = df.dropna(subset=["__d", "__r"])
439
+ grp = df.groupby(df["__d"].dt.date)["__r"]
440
+ daily = pd.DataFrame({
441
+ "date": grp.mean().index.astype(str),
442
+ "avg_rating": grp.mean().values.round(2),
443
+ "count": grp.count().values,
444
+ })
445
+ return json.dumps({
446
+ "app_filter": app_name or "all",
447
+ "rows": daily.to_dict("records"),
448
+ }, ensure_ascii=False)
449
+
450
+
451
+ @tool
452
+ def search_reviews_by_keyword(
453
+ keyword: str,
454
+ max_results: int = 20,
455
+ app_name: Optional[str] = None,
456
+ ) -> str:
457
+ """
458
+ Full-text search reviews for a keyword or phrase.
459
+ Optionally filter to a specific app.
460
+ Use this to investigate specific topics: 'crash', 'ads', 'login', etc.
461
+ """
462
+ df = _base_df(app_name)
463
+ s = _schema()
464
+ tc = s.get("text"); rc = s.get("rating")
465
+ if df is None or not tc:
466
+ return json.dumps({"error": "No text column."})
467
+
468
+ mask = df[tc].astype(str).str.lower().str.contains(
469
+ re.escape(keyword.lower()), na=False, regex=True)
470
+ cols = {tc: "text"}
471
+ if rc: cols[rc] = "rating"
472
+ ac = s.get("app")
473
+ if ac: cols[ac] = "app"
474
+
475
+ rows = (df[mask][list(cols.keys())]
476
+ .rename(columns=cols)
477
+ .head(max_results)
478
+ .to_dict("records"))
479
+ return json.dumps({
480
+ "keyword": keyword,
481
+ "app_filter": app_name or "all",
482
+ "count_matched": int(mask.sum()),
483
+ "returned": len(rows),
484
+ "reviews": rows,
485
+ }, ensure_ascii=False)
486
+
487
+
488
+ @tool
489
+ def get_app_comparison(metric: str = "avg_rating") -> str:
490
+ """
491
+ Compare all detected apps by a metric: avg_rating, pct_negative,
492
+ pct_positive, count, or helpful_votes.
493
+ Use when the CSV has multiple apps and you need to rank them.
494
+ """
495
+ breakdown = _ACTIVE.get("app_breakdown", [])
496
+ if not breakdown:
497
+ return json.dumps({"error": "No app breakdown computed yet."})
498
+ valid_metrics = {"avg_rating","pct_negative","pct_positive","count","helpful_votes"}
499
+ m = metric if metric in valid_metrics else "avg_rating"
500
+ ranked = sorted(breakdown, key=lambda x: x.get(m) or 0, reverse=(m != "pct_negative"))
501
+ return json.dumps({
502
+ "metric": m,
503
+ "ranking": [
504
+ {"rank": i+1, "app": r["app"], m: r.get(m)}
505
+ for i, r in enumerate(ranked)
506
+ ],
507
+ }, ensure_ascii=False)
508
+
509
+
510
+ @tool
511
+ def get_rating_distribution(app_name: Optional[str] = None) -> str:
512
+ """
513
+ Return star count breakdown {1: N, 2: N, 3: N, 4: N, 5: N}.
514
+ Optionally for a single app.
515
+ """
516
+ df = _base_df(app_name)
517
+ s = _schema(); rc = s.get("rating")
518
+ if df is None or not rc:
519
+ return json.dumps({"error": "No rating column."})
520
+ df["__r"] = pd.to_numeric(df[rc], errors="coerce")
521
+ dist = df["__r"].value_counts().sort_index()
522
+ return json.dumps({
523
+ "app_filter": app_name or "all",
524
+ "distribution": {str(int(k)): int(v) for k, v in dist.items() if not pd.isna(k)},
525
+ }, ensure_ascii=False)
526
+
527
+
528
+ # ══════════════════════════════════════════════════════════════════════════════
529
+ # ③ CODE-AGENT TOOL — runs pandas/stats code on HuggingFace Space
530
+ # ══════════════════════════════════════════════════════════════════════════════
531
+
532
+ @tool
533
+ def run_pandas_code(code: str) -> str:
534
+ """
535
+ Execute arbitrary pandas / statistics Python code on the review DataFrame
536
+ hosted in a HuggingFace Space code-execution endpoint.
537
+
538
+ Use this when you need:
539
+ - custom aggregations not covered by other tools
540
+ - statistical tests (e.g. t-test between two apps' ratings)
541
+ - pivot tables, cross-tabs, rolling averages
542
+ - complex filtering with multiple conditions
543
+
544
+ The remote environment has: pandas, numpy, scipy, statsmodels.
545
+ The DataFrame is available as `df` with the current schema columns.
546
+ The result must be assigned to a variable named `result`.
547
+
548
+ Example code:
549
+ import scipy.stats as stats
550
+ g1 = df[df['app']=='AppA']['score'].dropna()
551
+ g2 = df[df['app']=='AppB']['score'].dropna()
552
+ t, p = stats.ttest_ind(g1, g2)
553
+ result = {'t_stat': round(float(t),4), 'p_value': round(float(p),4)}
554
+ """
555
+ url = HF_CODE_AGENT_URL.rstrip("/") + "/execute"
556
+ if "YOUR-HF-USERNAME" in url:
557
+ return json.dumps({
558
+ "error": "HF_CODE_AGENT_URL not configured. "
559
+ "Set env var HF_CODE_AGENT_URL to your deployed HuggingFace Space URL.",
560
+ "hint": "See code_agent_space/app.py in this repo for the companion Space.",
561
+ })
562
+
563
+ # Send the CSV as base64 + code to the Space
564
+ df = _df()
565
+ if df is None:
566
+ return json.dumps({"error": "No dataframe loaded."})
567
+
568
+ import base64, io
569
+ buf = io.StringIO()
570
+ df.to_csv(buf, index=False)
571
+ csv_b64 = base64.b64encode(buf.getvalue().encode()).decode()
572
+
573
+ try:
574
+ resp = requests.post(
575
+ url,
576
+ json={"csv_b64": csv_b64, "code": code, "schema": _schema()},
577
+ timeout=30,
578
+ )
579
+ resp.raise_for_status()
580
+ return resp.text
581
+ except requests.exceptions.ConnectionError:
582
+ return json.dumps({"error": f"Cannot reach HF Space at {url}. Is it deployed?"})
583
+ except requests.exceptions.Timeout:
584
+ return json.dumps({"error": "Code agent timed out (>30s)."})
585
+ except Exception as e:
586
+ return json.dumps({"error": str(e)})
587
+
588
+
589
+ # All tools list — used by planner
590
+ TOOLS = [
591
+ get_reviews_by_rating,
592
+ get_most_helpful_reviews,
593
+ get_rating_timeseries,
594
+ search_reviews_by_keyword,
595
+ get_app_comparison,
596
+ get_rating_distribution,
597
+ run_pandas_code,
598
+ ]
599
+
600
+ TOOL_MAP = {t.name: t for t in TOOLS}
601
+
602
+ TOOL_DESCRIPTIONS = """
603
+ - get_reviews_by_rating(min_stars, max_stars, n, app_name?) — filter by star range 1-5. Use instead of separate positive/negative tools.
604
+ - get_most_helpful_reviews(n, app_name?) — most upvoted reviews
605
+ - get_rating_timeseries(app_name?) — daily avg rating trend
606
+ - search_reviews_by_keyword(keyword, max_results, app_name?) — full-text search
607
+ - get_app_comparison(metric) — rank all apps by metric (avg_rating|pct_negative|count…)
608
+ - get_rating_distribution(app_name?) — star count breakdown
609
+ - run_pandas_code(code) — custom pandas/scipy code on HuggingFace Space for complex stats
610
+ """
611
+
612
+
613
+ # ══════════════════════════════════════════════════════════════════════════════
614
+ # LLM HELPER
615
+ # ══════════════════════════════════════════════════════════════════════════════
616
+
617
+ def _llm_json(system: str, user: str, fallback_fn=None) -> dict | list:
618
+ if LLM is None:
619
+ return fallback_fn() if fallback_fn else {}
620
+ prompt = ChatPromptTemplate.from_messages([
621
+ SystemMessage(content=system),
622
+ ("human", "{u}")
623
+ ])
624
+ chain = prompt | LLM
625
+ try:
626
+ resp = chain.invoke({"u": user})
627
+ raw = getattr(resp, "content", str(resp)).strip()
628
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
629
+ raw = re.sub(r"\s*```$", "", raw)
630
+ return json.loads(raw)
631
+ except Exception as e:
632
+ _log(f"LLM JSON parse failed: {e}", "warn")
633
+ return fallback_fn() if fallback_fn else {}
634
+
635
+
636
+ # ══════════════════════════════════════════════════════════════════════════════
637
+ # NODE 1 — INGESTION
638
+ # ══════════════════════════════════════════════════════════════════════════════
639
+
640
+ def node_ingest(state: ReviewState) -> dict:
641
+ _log("Node [ingest]", "agent")
642
+
643
+ if state.get("raw_df") is not None:
644
+ df = state["raw_df"]
645
+ _log(f"Using provided DataFrame: {len(df):,} rows", "ok")
646
+ return {
647
+ "total_rows": len(df),
648
+ "columns": list(df.columns),
649
+ "messages": [HumanMessage(content=f"Using provided data with {len(df)} reviews")],
650
+ }
651
+
652
+ fp = state["filepath"]
653
+ if not fp:
654
+ return {"errors": ["ingest: no filepath or dataframe provided"]}
655
+
656
+ ext = Path(fp).suffix.lower()
657
+ try:
658
+ if ext in (".xls", ".xlsx", ".xlsm"):
659
+ df = pd.read_excel(fp)
660
+ else:
661
+ for enc in ("utf-8", "utf-8-sig", "latin-1", "cp1252"):
662
+ try:
663
+ df = pd.read_csv(fp, encoding=enc); break
664
+ except Exception: continue
665
+ else:
666
+ raise ValueError(f"Cannot decode {fp}")
667
+ df = df.dropna(how="all")
668
+ _log(f"Loaded {len(df):,} rows × {len(df.columns)} cols", "ok")
669
+ return {
670
+ "raw_df": df, "total_rows": len(df),
671
+ "columns": list(df.columns),
672
+ "messages": [HumanMessage(content=f"Loaded {len(df)} reviews")],
673
+ }
674
+ except Exception as e:
675
+ return {"errors": [f"ingest: {e}"],
676
+ "messages": [HumanMessage(content=f"ERROR loading: {e}")]}
677
+
678
+
679
+ # ══════════════════════════════════════════════════════════════════════════════
680
+ # NODE 2 — LLM SCHEMA DETECTION
681
+ # ══════════════════════════════════════════════════════════════════════════════
682
+
683
+ _SCHEMA_SYS = """You are a CSV schema analyst.
684
+ Given column names and sample rows from a user-review dataset, map each role to the exact column name.
685
+ Return ONLY valid JSON (no markdown):
686
+ {{
687
+ "text": "<review text column or null>",
688
+ "rating": "<numeric star/score column or null>",
689
+ "date": "<date/timestamp column or null>",
690
+ "app": "<app/product/game identifier column or null>",
691
+ "user": "<reviewer name/id column or null>",
692
+ "helpful": "<helpful/upvote count column or null>",
693
+ "confidence": "high|medium|low",
694
+ "reasoning": "one sentence"
695
+ }}
696
+ Use EXACT column names. null if uncertain."""
697
+
698
+
699
+ def node_schema(state: ReviewState) -> dict:
700
+ _log("Node [schema]", "agent")
701
+ df = state.get("raw_df")
702
+ if df is None:
703
+ return {"errors": ["schema: no dataframe"]}
704
+
705
+ sample = df.head(SAMPLE_ROWS_FOR_SCHEMA).to_dict(orient="records")
706
+ result = _llm_json(
707
+ _SCHEMA_SYS,
708
+ json.dumps({"columns": state["columns"], "sample_rows": sample}),
709
+ fallback_fn=lambda: _heuristic_schema(state["columns"]),
710
+ )
711
+ schema = {k: result.get(k) for k in ["text","rating","date","app","user","helpful"]}
712
+ conf = result.get("confidence","low")
713
+ _log(f"Schema ({conf}): {schema}", "ok")
714
+
715
+ # Detect unique apps so tools can be app-aware
716
+ apps: list[str] = []
717
+ ac = schema.get("app")
718
+ if ac and df is not None and ac in df.columns:
719
+ apps = sorted(df[ac].dropna().astype(str).unique().tolist())
720
+ _log(f"Detected {len(apps)} apps: {apps[:5]}", "info")
721
+
722
+ return {
723
+ "schema": schema, "schema_confidence": conf,
724
+ "detected_apps": apps,
725
+ "messages": [AIMessage(content=f"Schema ({conf}): {schema}. Apps: {apps[:5]}")],
726
+ }
727
+
728
+
729
+ def _heuristic_schema(cols: list[str]) -> dict:
730
+ low = {c.lower(): c for c in cols}
731
+ def first(pats):
732
+ for p in pats:
733
+ for cl, c in low.items():
734
+ if p in cl: return c
735
+ return None
736
+ return {
737
+ "text": first(["content","review","text","body","comment"]),
738
+ "rating": first(["score","rating","stars","rate","grade"]),
739
+ "date": first(["date","time","created","posted","at"]),
740
+ "app": first(["app","product","game","title","name"]),
741
+ "user": first(["user","reviewer","author"]),
742
+ "helpful": first(["helpful","thumbs","vote","like","useful"]),
743
+ "confidence": "low", "reasoning": "heuristic fallback",
744
+ }
745
+
746
+
747
+ # ══════════════════════════════════════════════════════════════════════════════
748
+ # NODE 3 — STATISTICAL ANALYSIS
749
+ # ══════════════════════════════════════════════════════════════════════════════
750
+
751
+ def node_stats(state: ReviewState) -> dict:
752
+ _log("Node [stats]", "agent")
753
+ df = state.get("raw_df"); s = state.get("schema", {})
754
+ rc = s.get("rating"); tc = s.get("text")
755
+ if df is None or not rc:
756
+ return {"stats": {}}
757
+
758
+ df2 = df.copy()
759
+ df2["__r"] = pd.to_numeric(df2[rc], errors="coerce")
760
+ valid = df2["__r"].dropna()
761
+
762
+ stats: dict[str, Any] = {
763
+ "total_reviews": int(len(df2)),
764
+ "rated_reviews": int(len(valid)),
765
+ "avg_rating": round(float(valid.mean()), 3) if len(valid) else None,
766
+ "median_rating": float(valid.median()) if len(valid) else None,
767
+ "std_rating": round(float(valid.std()), 3) if len(valid) else None,
768
+ "pct_positive": round(float((valid >= 4).mean() * 100), 1),
769
+ "pct_negative": round(float((valid <= 2).mean() * 100), 1),
770
+ "pct_neutral": round(float(((valid > 2) & (valid < 4)).mean() * 100), 1),
771
+ "rating_distribution": {
772
+ str(int(k)): int(v)
773
+ for k, v in df2["__r"].value_counts().sort_index().items()
774
+ if not pd.isna(k)
775
+ },
776
+ }
777
+
778
+ if tc:
779
+ lens = df2[tc].dropna().astype(str).str.len()
780
+ stats["avg_review_length"] = int(lens.mean())
781
+ stats["short_reviews_pct"] = round(float((lens < 20).mean() * 100), 1)
782
+
783
+ hc = s.get("helpful")
784
+ if hc:
785
+ df2["__h"] = pd.to_numeric(df2[hc], errors="coerce").fillna(0)
786
+ stats["total_helpful_votes"] = int(df2["__h"].sum())
787
+
788
+ dc = s.get("date")
789
+ if dc:
790
+ try:
791
+ df2["__d"] = pd.to_datetime(df2[dc], errors="coerce")
792
+ daily = df2.dropna(subset=["__d","__r"]).groupby(df2["__d"].dt.date)["__r"].mean()
793
+ if len(daily) > 7:
794
+ m, std = daily.mean(), daily.std()
795
+ bad = daily[daily < (m - ANOMALY_SIGMA * std)]
796
+ stats["anomaly_days"] = [
797
+ {"date": str(d), "avg_rating": round(float(v), 2)}
798
+ for d, v in bad.items()
799
+ ]
800
+ except Exception:
801
+ pass
802
+
803
+ # Per-app breakdown
804
+ app_rows = []
805
+ ac = s.get("app")
806
+ if ac:
807
+ for name, grp in df2.groupby(ac):
808
+ gr = pd.to_numeric(grp["__r"], errors="coerce")
809
+ row: dict[str, Any] = {
810
+ "app": str(name), "count": int(len(grp)),
811
+ "avg_rating": round(float(gr.mean()), 2) if len(gr) else None,
812
+ "pct_negative": round(float((gr <= 2).mean() * 100), 1),
813
+ "pct_positive": round(float((gr >= 4).mean() * 100), 1),
814
+ }
815
+ if hc:
816
+ row["helpful_votes"] = int(
817
+ pd.to_numeric(grp[hc], errors="coerce").fillna(0).sum())
818
+ app_rows.append(row)
819
+ app_rows.sort(key=lambda x: x.get("avg_rating") or 5)
820
+ stats["apps_analyzed"] = len(app_rows)
821
+
822
+ _log(f"Stats: avg={stats.get('avg_rating')} neg={stats.get('pct_negative')}%", "ok")
823
+ return {
824
+ "stats": stats, "app_breakdown": app_rows,
825
+ "messages": [AIMessage(content=(
826
+ f"Stats: {stats['total_reviews']} reviews, avg {stats.get('avg_rating')}/5"))],
827
+ }
828
+
829
+
830
+ # ══════════════════════════════════════════════════════════════════════════════
831
+ # NODE 4 — ① BERT SENTIMENT (local, zero token cost)
832
+ # ══════════════════════════════════════════════════════════════════════════════
833
+
834
+ def node_nlp(state: ReviewState) -> dict:
835
+ _log("Node [nlp/BERT]", "agent")
836
+ df = state.get("raw_df"); s = state.get("schema", {})
837
+ tc = s.get("text")
838
+ if df is None or not tc:
839
+ return {"sentiment": _rating_sentiment_fallback(state)}
840
+
841
+ # Sample
842
+ sample_df = df.sample(min(MAX_REVIEWS_NLP, len(df)), random_state=42)
843
+ texts = sample_df[tc].fillna("").astype(str).tolist()
844
+
845
+ # ① Try BERT first (free, local)
846
+ bert_result = bert_sentiment(texts)
847
+ if bert_result:
848
+ _log(f"BERT sentiment: pos={bert_result['pct_positive']}% "
849
+ f"neg={bert_result['pct_negative']}% tone={bert_result['overall_tone']}", "ok")
850
+ return {
851
+ "sentiment": bert_result,
852
+ "sample_texts": texts[:500],
853
+ "messages": [AIMessage(content=(
854
+ f"BERT sentiment: {bert_result['overall_tone']} "
855
+ f"(pos={bert_result['pct_positive']}% neg={bert_result['pct_negative']}%)"))],
856
+ }
857
+
858
+ # ② Fall back to rating-based heuristic (no transformers installed)
859
+ _log("BERT unavailable — using rating heuristic", "warn")
860
+ fallback = _rating_sentiment_fallback(state)
861
+ return {
862
+ "sentiment": fallback,
863
+ "sample_texts": texts[:500],
864
+ }
865
+
866
+
867
+ def _rating_sentiment_fallback(state: ReviewState) -> dict:
868
+ stats = state.get("stats", {})
869
+ return {
870
+ "pct_positive": stats.get("pct_positive", 0),
871
+ "pct_neutral": stats.get("pct_neutral", 0),
872
+ "pct_negative": stats.get("pct_negative", 0),
873
+ "overall_tone": "Unknown",
874
+ "method": "rating-heuristic (no BERT)",
875
+ "themes": [], "phrases": [],
876
+ }
877
+
878
+
879
+ # ══════════════════════════════════════════════════════════════════════════════
880
+ # NODE 5 — LLM CLUSTERING (topic discovery, not keyword matching)
881
+ # ══════════════════════════════════════════════════════════════════════════════
882
+
883
+ _CLUSTER_SYS = f"""You are a product issue analyst.
884
+ Given a mixed sample of user reviews, discover distinct topic clusters.
885
+ Return ONLY valid JSON:
886
+ {{
887
+ "clusters": [
888
+ {{
889
+ "label": "Short name",
890
+ "type": "issue|praise|request|general",
891
+ "description": "1-2 sentence summary",
892
+ "frequency_signal": "high|medium|low",
893
+ "severity": "critical|high|medium|low",
894
+ "example_quote": "verbatim short quote",
895
+ "keywords": ["word1","word2"]
896
+ }}
897
+ ]
898
+ }}
899
+ Max {MAX_CLUSTERS} clusters. Merge near-duplicates. Issues first."""
900
+
901
+
902
+ def node_cluster(state: ReviewState) -> dict:
903
+ _log("Node [cluster]", "agent")
904
+ df = state.get("raw_df"); s = state.get("schema", {})
905
+ rc = s.get("rating"); tc = s.get("text")
906
+ if df is None or not tc or LLM is None:
907
+ return {"clusters": []}
908
+
909
+ df2 = df.copy()
910
+ if rc:
911
+ df2["__r"] = pd.to_numeric(df2[rc], errors="coerce")
912
+ neg = df2.nsmallest(50, "__r")
913
+ pos = df2.nlargest(30, "__r")
914
+ rnd = df2.sample(min(50, len(df2)), random_state=7)
915
+ combined = pd.concat([neg, pos, rnd]).drop_duplicates(subset=[tc])
916
+ else:
917
+ combined = df2.sample(min(130, len(df2)), random_state=7)
918
+
919
+ sample = [{"text": str(r[tc])[:280], "rating": r.get(rc)}
920
+ for _, r in combined.iterrows()][:130]
921
+ result = _llm_json(_CLUSTER_SYS, json.dumps(sample))
922
+ clusters = result.get("clusters", [])
923
+
924
+ _sev = {"critical":0,"high":1,"medium":2,"low":3}
925
+ _typ = {"issue":0,"request":1,"general":2,"praise":3}
926
+ clusters.sort(key=lambda c: (
927
+ _typ.get(c.get("type","general"),9),
928
+ _sev.get(c.get("severity","low"),9)))
929
+
930
+ _log(f"Discovered {len(clusters)} clusters", "ok")
931
+ return {
932
+ "clusters": clusters,
933
+ "messages": [AIMessage(content=f"Found {len(clusters)} topic clusters")],
934
+ }
935
+
936
+
937
+ # ══════════════════════════════════════════════════════════════════════════════
938
+ # NODE 6 — ④ PLANNER (ReAct with scope guard)
939
+ # ══════════════════════════════════════════════════════════════════════════════
940
+
941
+ _PLANNER_SYS = """You are an analytical planner for a review intelligence system.
942
+ A user asked a specific question. Decide which tools to call to best answer it.
943
+
944
+ Available tools:
945
+ {tools}
946
+
947
+ Already computed context:
948
+ {context}
949
+
950
+ Detected apps in dataset: {apps}
951
+
952
+ User query: {query}
953
+
954
+ Rules:
955
+ - Use get_reviews_by_rating(min_stars, max_stars) instead of separate positive/negative tools.
956
+ - If multiple apps exist and the query targets one, pass app_name to every tool call.
957
+ - Use run_pandas_code only for custom stats not covered by other tools.
958
+ - Choose 1-3 tools maximum. Do not repeat a tool with identical arguments.
959
+
960
+ Return ONLY valid JSON:
961
+ {{
962
+ "reasoning": "1-2 sentences",
963
+ "calls": [{{"tool": "name", "args": {{...}}}}],
964
+ "done": false
965
+ }}
966
+ Set "done": true if the context already fully answers the query with no more tools needed.
967
+ """
968
+
969
+
970
+ def node_planner(state: ReviewState) -> dict:
971
+ _log("Node [planner]", "agent")
972
+ query = state.get("user_query", "").strip()
973
+ itr = state.get("planner_iter", 0)
974
+
975
+ # ④ Scope guard — terminate if iterations exceeded
976
+ if itr >= MAX_PLANNER_ITERATIONS:
977
+ _log(f"Planner reached max iterations ({MAX_PLANNER_ITERATIONS}) — terminating", "warn")
978
+ return {
979
+ "planner_notes": f"Terminated after {itr} iterations (scope guard).",
980
+ "planner_iter": itr,
981
+ "messages": [AIMessage(content="Planner: max iterations reached, proceeding to report.")],
982
+ }
983
+
984
+ if not query or LLM is None:
985
+ return {"planner_notes": "No query or LLM unavailable.", "planner_iter": itr}
986
+
987
+ # Load active state for tool closures
988
+ _ACTIVE.update(state)
989
+
990
+ context = {
991
+ "total_reviews": state.get("stats",{}).get("total_reviews"),
992
+ "avg_rating": state.get("stats",{}).get("avg_rating"),
993
+ "clusters": [c.get("label") for c in state.get("clusters",[])[:5]],
994
+ "overall_tone": state.get("sentiment",{}).get("overall_tone"),
995
+ "has_date": bool(state.get("schema",{}).get("date")),
996
+ "has_helpful": bool(state.get("schema",{}).get("helpful")),
997
+ "tools_called": [t["tool"] for t in state.get("tool_results",[])],
998
+ }
999
+
1000
+ plan = _llm_json(
1001
+ _PLANNER_SYS.format(
1002
+ tools=TOOL_DESCRIPTIONS,
1003
+ context=json.dumps(context),
1004
+ apps=state.get("detected_apps", [])[:10],
1005
+ query=query,
1006
+ ),
1007
+ "",
1008
+ )
1009
+
1010
+ reasoning = plan.get("reasoning", "")
1011
+ done = plan.get("done", False)
1012
+ _log(f"Planner iter={itr+1}: {reasoning}", "info")
1013
+
1014
+ # ④ If planner says done, skip tool calls
1015
+ if done:
1016
+ return {
1017
+ "planner_notes": reasoning,
1018
+ "planner_iter": itr + 1,
1019
+ "messages": [AIMessage(content=f"Planner done: {reasoning}")],
1020
+ }
1021
+
1022
+ tool_results = list(state.get("tool_results", []))
1023
+ for call in plan.get("calls", []):
1024
+ tname = call.get("tool","")
1025
+ args = call.get("args",{})
1026
+ t = TOOL_MAP.get(tname)
1027
+ if not t:
1028
+ continue
1029
+ _log(f" Tool: {tname}({args})", "info")
1030
+ try:
1031
+ result = t.invoke(args)
1032
+ tool_results.append({"tool": tname, "args": args, "result": result, "ok": True})
1033
+ except Exception as e:
1034
+ tool_results.append({"tool": tname, "args": args, "error": str(e), "ok": False})
1035
+ _log(f" Tool {tname} failed: {e}", "warn")
1036
+
1037
+ return {
1038
+ "tool_results": tool_results,
1039
+ "planner_notes": reasoning,
1040
+ "planner_iter": itr + 1,
1041
+ "messages": [AIMessage(content=f"Planner iter {itr+1}: {reasoning}")],
1042
+ }
1043
+
1044
+
1045
+ # ══════════════════════════════════════════════════════════════════════════════
1046
+ # NODE 7 — ③ PINECONE RAG (native integrated embedding, no OpenAI needed)
1047
+ # ══════════════════════════════════════════════════════════════════════════════
1048
+
1049
+ def node_rag(state: ReviewState) -> dict:
1050
+ _log("Node [rag/pinecone]", "agent")
1051
+ if not state.get("use_pinecone") or not HAS_PINECONE:
1052
+ return {"rag_context": ""}
1053
+
1054
+ api_key = os.getenv("PINECONE_API_KEY", "")
1055
+ if not api_key:
1056
+ _log("PINECONE_API_KEY missing — skipping RAG", "warn")
1057
+ return {"rag_context": ""}
1058
+
1059
+ try:
1060
+ pc = PineconeClient(api_key=api_key)
1061
+
1062
+ # ③ Create index with Pinecone's integrated embedding model (no OpenAI)
1063
+ existing = [idx.name for idx in pc.list_indexes()]
1064
+ if PINECONE_INDEX_NAME not in existing:
1065
+ _log(f"Creating Pinecone index '{PINECONE_INDEX_NAME}' with {PINECONE_EMBED_MODEL}…", "info")
1066
+ pc.create_index_for_model(
1067
+ name=PINECONE_INDEX_NAME,
1068
+ cloud="aws",
1069
+ region="us-east-1",
1070
+ embed={
1071
+ "model": PINECONE_EMBED_MODEL,
1072
+ "field_map": {"text": "chunk_text"},
1073
+ },
1074
+ )
1075
+ time.sleep(3) # wait for index to be ready
1076
+
1077
+ idx = pc.Index(PINECONE_INDEX_NAME)
1078
+
1079
+ # Upsert review sample as records — Pinecone auto-embeds chunk_text
1080
+ texts = state.get("sample_texts", [])[:500]
1081
+ if texts:
1082
+ records = [
1083
+ {"_id": f"rev_{i}_{state.get('run_id','')}", "chunk_text": t}
1084
+ for i, t in enumerate(texts) if t.strip()
1085
+ ]
1086
+ # Batch upsert
1087
+ for i in range(0, len(records), PINECONE_UPSERT_BATCH):
1088
+ batch = records[i:i + PINECONE_UPSERT_BATCH]
1089
+ idx.upsert_records(namespace=PINECONE_NAMESPACE, records=batch)
1090
+ _log(f"Upserted {len(records)} reviews to Pinecone", "ok")
1091
+
1092
+ # ③ Query: use pc.inference.embed() for the query vector (Pinecone-native)
1093
+ query = state.get("user_query") or "most common user complaints and praise"
1094
+ q_embeddings = pc.inference.embed(
1095
+ model=PINECONE_EMBED_MODEL,
1096
+ inputs=[query],
1097
+ parameters={"input_type": "query", "truncate": "END"},
1098
+ )
1099
+ q_vector = q_embeddings[0].values
1100
+
1101
+ results = idx.query(
1102
+ namespace=PINECONE_NAMESPACE,
1103
+ vector=q_vector,
1104
+ top_k=PINECONE_TOP_K,
1105
+ include_metadata=True,
1106
+ )
1107
+
1108
+ matches = results.get("matches", [])
1109
+ rag_context = "\n\n".join(
1110
+ f"[Similar review {i+1} score={round(m.get('score',0),3)}]: "
1111
+ f"{m.get('metadata',{}).get('chunk_text','')}"
1112
+ for i, m in enumerate(matches)
1113
+ )
1114
+ _log(f"RAG: retrieved {len(matches)} similar reviews", "ok")
1115
+ return {
1116
+ "rag_context": rag_context,
1117
+ "messages": [AIMessage(content=f"RAG: {len(matches)} similar reviews retrieved")],
1118
+ }
1119
+
1120
+ except Exception as e:
1121
+ _log(f"Pinecone RAG failed: {e}", "warn")
1122
+ return {"rag_context": ""}
1123
+
1124
+
1125
+ # ══════════════════════════════════════════════════════════════════════════════
1126
+ # NODE 8 — REPORT SYNTHESISER
1127
+ # ══════════════════════════════════════════════════════════════════════════════
1128
+
1129
+ _REPORT_SYS = """You are a senior product intelligence analyst.
1130
+ Produce a structured insight report from pipeline data, tool results, and RAG context.
1131
+ User query: {query}
1132
+ RAG context: {rag}
1133
+
1134
+ Return ONLY valid JSON:
1135
+ {{
1136
+ "executive_summary": "2-3 sentences. Lead with direct answer if query present.",
1137
+ "direct_answer": "1-2 sentence direct answer, or null.",
1138
+ "top_problems": [
1139
+ {{"issue":"","description":"","severity":"critical|high|medium|low",
1140
+ "frequency":"high|medium|low","evidence":""}}
1141
+ ],
1142
+ "key_strengths": [{{"strength":"","description":"","evidence":""}}],
1143
+ "trend_observations": [{{"observation":"","detail":"","data_ref":""}}],
1144
+ "anomalies": [{{"anomaly":"","type":"Spike|Pattern|Outlier|Trend",
1145
+ "detail":"","hypothesis":""}}],
1146
+ "recommendations": [
1147
+ {{"priority":"critical|high|medium|low","action":"",
1148
+ "rationale":"","expected_impact":""}}
1149
+ ],
1150
+ "confidence_note": ""
1151
+ }}"""
1152
+
1153
+
1154
+ def node_report(state: ReviewState) -> dict:
1155
+ _log("Node [report]", "agent")
1156
+ payload = {
1157
+ "stats": state.get("stats",{}),
1158
+ "sentiment": state.get("sentiment",{}),
1159
+ "clusters": state.get("clusters",[])[:MAX_CLUSTERS],
1160
+ "app_breakdown": state.get("app_breakdown",[])[:10],
1161
+ "tool_results": [
1162
+ {"tool": t["tool"], "result": str(t.get("result",""))[:400]}
1163
+ for t in state.get("tool_results",[]) if t.get("ok")
1164
+ ],
1165
+ }
1166
+
1167
+ if LLM is None:
1168
+ return {"report": _heuristic_report(state)}
1169
+
1170
+ system = _REPORT_SYS.format(
1171
+ query=state.get("user_query") or "(general analysis)",
1172
+ rag=state.get("rag_context") or "None",
1173
+ )
1174
+ report = _llm_json(system, json.dumps(payload),
1175
+ fallback_fn=lambda: _heuristic_report(state))
1176
+ _log("Report synthesised", "ok")
1177
+ return {
1178
+ "report": report,
1179
+ "messages": [AIMessage(content="Report synthesis complete.")],
1180
+ }
1181
+
1182
+
1183
+ def _heuristic_report(state: ReviewState) -> dict:
1184
+ clusters = state.get("clusters",[])
1185
+ stats = state.get("stats",{})
1186
+ sent = state.get("sentiment",{})
1187
+ problems = [
1188
+ {"issue": c.get("label","?"), "description": c.get("description",""),
1189
+ "severity": c.get("severity","medium"), "frequency": c.get("frequency_signal","medium"),
1190
+ "evidence": c.get("example_quote","")[:150]}
1191
+ for c in clusters if c.get("type")=="issue"
1192
+ ][:5]
1193
+ return {
1194
+ "executive_summary": (
1195
+ f"Analysed {stats.get('total_reviews','?')} reviews. "
1196
+ f"Avg rating {stats.get('avg_rating','?')}/5. "
1197
+ f"Tone: {sent.get('overall_tone','?')} (method: {sent.get('method','?')})."),
1198
+ "direct_answer": None,
1199
+ "top_problems": problems,
1200
+ "key_strengths": [],
1201
+ "trend_observations": [],
1202
+ "anomalies": [
1203
+ {"anomaly": f"Drop on {d['date']}", "type": "Spike",
1204
+ "detail": f"Avg {d['avg_rating']}", "hypothesis": "Possible bad update"}
1205
+ for d in stats.get("anomaly_days",[])[:3]
1206
+ ],
1207
+ "recommendations": [
1208
+ {"priority": p["severity"], "action": f"Fix: {p['issue']}",
1209
+ "rationale": p["description"], "expected_impact": "Better ratings"}
1210
+ for p in problems[:4]
1211
+ ],
1212
+ "confidence_note": "Heuristic fallback — LLM unavailable.",
1213
+ }
1214
+
1215
+
1216
+ # ══════════════════════════════════════════════════════════════════════════════
1217
+ # CONDITIONAL EDGES
1218
+ # ══════════════════════════════════════════════════════════════════════════════
1219
+
1220
+ def _route_after_stats(state: ReviewState) -> str:
1221
+ return "nlp" if state.get("schema",{}).get("text") else "planner"
1222
+
1223
+ def _route_after_cluster(state: ReviewState) -> str:
1224
+ if state.get("user_query","").strip() and LLM is not None:
1225
+ return "planner"
1226
+ return "rag"
1227
+
1228
+ def _route_after_planner(state: ReviewState) -> str:
1229
+ # ④ Also route to report if scope guard triggered
1230
+ itr = state.get("planner_iter", 0)
1231
+ done = itr >= MAX_PLANNER_ITERATIONS
1232
+ if state.get("use_pinecone") and HAS_PINECONE and not done:
1233
+ return "rag"
1234
+ return "report"
1235
+
1236
+
1237
+ # ══════════════════════════════════════════════════════════════════════════════
1238
+ # GRAPH ASSEMBLY
1239
+ # ══════════════════════════════════════════════════════════════════════════════
1240
+
1241
+ def build_graph():
1242
+ g = StateGraph(ReviewState)
1243
+
1244
+ g.add_node("ingest", node_ingest)
1245
+ g.add_node("schema", node_schema)
1246
+ g.add_node("stats", node_stats)
1247
+ g.add_node("nlp", node_nlp)
1248
+ g.add_node("cluster", node_cluster)
1249
+ g.add_node("planner", node_planner)
1250
+ g.add_node("rag", node_rag)
1251
+ g.add_node("report", node_report)
1252
+
1253
+ g.add_edge(START, "ingest")
1254
+ g.add_edge("ingest", "schema")
1255
+ g.add_edge("schema", "stats")
1256
+
1257
+ g.add_conditional_edges("stats", _route_after_stats,
1258
+ {"nlp":"nlp","planner":"planner"})
1259
+ g.add_edge("nlp", "cluster")
1260
+
1261
+ g.add_conditional_edges("cluster", _route_after_cluster,
1262
+ {"planner":"planner","rag":"rag"})
1263
+
1264
+ g.add_conditional_edges("planner", _route_after_planner,
1265
+ {"rag":"rag","report":"report"})
1266
+
1267
+ g.add_edge("rag", "report")
1268
+ g.add_edge("report", END)
1269
+
1270
+ return g.compile()
1271
+
1272
+
1273
+ # ══════════════════════════════════════════════════════════════════════════════
1274
+ # RENDERER
1275
+ # ══════════════════════════════════════════════════════════════════════════════
1276
+
1277
+ def render(state: ReviewState):
1278
+ if RICH: _render_rich(state)
1279
+ else: _render_plain(state)
1280
+
1281
+
1282
+ def _render_rich(state: ReviewState):
1283
+ stats = state.get("stats",{})
1284
+ sent = state.get("sentiment",{})
1285
+ clusters = state.get("clusters",[])
1286
+ apps = state.get("app_breakdown",[])
1287
+ report = state.get("report",{})
1288
+ query = state.get("user_query","")
1289
+ tools = state.get("tool_results",[])
1290
+ itr = state.get("planner_iter",0)
1291
+
1292
+ console.rule("[bold cyan]REVIEW INTELLIGENCE v4 · LangGraph[/bold cyan]")
1293
+
1294
+ if query and report.get("direct_answer"):
1295
+ console.print(Panel(
1296
+ f"[bold yellow]Q:[/bold yellow] {query}\n\n"
1297
+ f"[bold green]{report['direct_answer']}[/bold green]",
1298
+ title="[bold]Direct Answer[/bold]", border_style="bright_green"))
1299
+
1300
+ console.print(Panel(
1301
+ f"[italic]{report.get('executive_summary','')}[/italic]",
1302
+ title="[bold]Executive Summary[/bold]", border_style="cyan"))
1303
+
1304
+ # Metrics
1305
+ t = Table(box=box.SIMPLE, show_header=False)
1306
+ t.add_column("", style="dim"); t.add_column("", style="bold")
1307
+ method_tag = sent.get("method","")
1308
+ for k, v in [
1309
+ ("Total Reviews", f"{stats.get('total_reviews','?'):,}"),
1310
+ ("Avg Rating", f"{stats.get('avg_rating','?')} / 5 σ={stats.get('std_rating','?')}"),
1311
+ ("% Positive", f"[green]{sent.get('pct_positive','?')}%[/green]"),
1312
+ ("% Neutral", f"[blue]{sent.get('pct_neutral','?')}%[/blue]"),
1313
+ ("% Negative", f"[red]{sent.get('pct_negative','?')}%[/red]"),
1314
+ ("Tone", str(sent.get("overall_tone","?"))),
1315
+ ("Sentiment method", f"[dim]{method_tag}[/dim]"),
1316
+ ("LLM Provider", f"[dim]{_PROVIDER_TAG}[/dim]"),
1317
+ ("Planner iters", str(itr)),
1318
+ ]:
1319
+ t.add_row(k, v)
1320
+ console.print(Panel(t, title="[bold]Key Metrics[/bold]", border_style="green"))
1321
+
1322
+ # Rating distribution bar
1323
+ console.print("[bold]Rating Distribution[/bold]")
1324
+ for star in [5,4,3,2,1]:
1325
+ cnt = stats.get("rating_distribution",{}).get(str(star), 0)
1326
+ total = max(1, stats.get("total_reviews",1))
1327
+ bar = "█" * int(cnt/total*40)
1328
+ color = "green" if star>=4 else "yellow" if star==3 else "red"
1329
+ console.print(f" {'★'*star:<5} [{color}]{bar:<40}[/{color}] {cnt:,}")
1330
+ console.print()
1331
+
1332
+ # Detected apps
1333
+ det_apps = state.get("detected_apps",[])
1334
+ if det_apps:
1335
+ console.print(Panel(
1336
+ " ".join(f"[cyan]{a}[/cyan]" for a in det_apps[:15]),
1337
+ title=f"[bold]Detected Apps ({len(det_apps)})[/bold]",
1338
+ border_style="dim"))
1339
+ console.print()
1340
+
1341
+ # Clusters
1342
+ if clusters:
1343
+ t = Table(title="LLM-Discovered Clusters", box=box.ROUNDED, border_style="magenta")
1344
+ t.add_column("Type",width=8); t.add_column("Label",style="bold")
1345
+ t.add_column("Freq",width=7); t.add_column("Severity",width=10)
1346
+ t.add_column("Description",style="dim")
1347
+ for c in clusters[:10]:
1348
+ typ = c.get("type","?")
1349
+ tc_ = "green" if typ=="praise" else "red" if typ=="issue" else "blue"
1350
+ sev = c.get("severity","?")
1351
+ sc = "red" if sev in("critical","high") else "yellow" if sev=="medium" else "blue"
1352
+ t.add_row(f"[{tc_}]{typ}[/{tc_}]", c.get("label",""),
1353
+ c.get("frequency_signal",""), f"[{sc}]{sev}[/{sc}]",
1354
+ c.get("description","")[:80])
1355
+ console.print(t); console.print()
1356
+
1357
+ # Top problems
1358
+ if report.get("top_problems"):
1359
+ t = Table(title="Top Problems", box=box.ROUNDED, border_style="red")
1360
+ t.add_column("#",width=3,style="dim"); t.add_column("Issue",style="bold")
1361
+ t.add_column("Severity",width=10); t.add_column("Evidence",style="dim")
1362
+ for i, p in enumerate(report["top_problems"],1):
1363
+ sc = "red" if p.get("severity") in("critical","high") else \
1364
+ "yellow" if p.get("severity")=="medium" else "blue"
1365
+ t.add_row(str(i), p.get("issue",""),
1366
+ f"[{sc}]{p.get('severity','')}[/{sc}]",
1367
+ p.get("evidence","")[:90])
1368
+ console.print(t); console.print()
1369
+
1370
+ # Key strengths
1371
+ if report.get("key_strengths"):
1372
+ t = Table(title="Key Strengths", box=box.ROUNDED, border_style="green")
1373
+ t.add_column("Strength",style="bold"); t.add_column("Evidence",style="dim")
1374
+ for p in report["key_strengths"]:
1375
+ t.add_row(p.get("strength",""), p.get("evidence","")[:100])
1376
+ console.print(t); console.print()
1377
+
1378
+ # Anomalies
1379
+ if report.get("anomalies"):
1380
+ t = Table(title="Anomalies", box=box.ROUNDED, border_style="yellow")
1381
+ t.add_column("Anomaly",style="bold"); t.add_column("Type",width=10)
1382
+ t.add_column("Detail",style="dim"); t.add_column("Hypothesis",style="dim")
1383
+ for a in report["anomalies"]:
1384
+ t.add_row(a.get("anomaly",""), a.get("type",""),
1385
+ a.get("detail","")[:70], a.get("hypothesis","")[:60])
1386
+ console.print(t); console.print()
1387
+
1388
+ # Per-app
1389
+ if apps:
1390
+ t = Table(title="Per-App Breakdown", box=box.ROUNDED, border_style="cyan")
1391
+ t.add_column("App",style="bold"); t.add_column("Reviews",justify="right")
1392
+ t.add_column("Avg",justify="right"); t.add_column("% Neg",justify="right")
1393
+ t.add_column("% Pos",justify="right")
1394
+ for a in apps:
1395
+ avg = a.get("avg_rating") or 5
1396
+ col = "red" if avg<2.5 else "yellow" if avg<3.5 else "green"
1397
+ t.add_row(str(a["app"])[:40], str(a["count"]),
1398
+ f"[{col}]{avg}[/{col}]",
1399
+ f"{a.get('pct_negative','?')}%",
1400
+ f"{a.get('pct_positive','?')}%")
1401
+ console.print(t); console.print()
1402
+
1403
+ # Recommendations
1404
+ if report.get("recommendations"):
1405
+ t = Table(title="Recommendations", box=box.ROUNDED, border_style="bright_white")
1406
+ t.add_column("#",width=3,style="dim"); t.add_column("Priority",width=10)
1407
+ t.add_column("Action",style="bold"); t.add_column("Impact",style="dim")
1408
+ for i, r in enumerate(report["recommendations"],1):
1409
+ pc = "red" if r.get("priority") in("critical","high") else \
1410
+ "yellow" if r.get("priority")=="medium" else "blue"
1411
+ t.add_row(str(i), f"[{pc}]{r.get('priority','')}[/{pc}]",
1412
+ r.get("action",""), r.get("expected_impact","")[:70])
1413
+ console.print(t); console.print()
1414
+
1415
+ # Tool call log
1416
+ if tools:
1417
+ t = Table(title="Tool Calls", box=box.SIMPLE, border_style="dim")
1418
+ t.add_column("Tool",style="dim"); t.add_column("Args",style="dim"); t.add_column("✓",width=3)
1419
+ for tc_ in tools:
1420
+ ok = "[green]✓[/green]" if tc_.get("ok") else "[red]✗[/red]"
1421
+ t.add_row(tc_.get("tool",""), str(tc_.get("args",{}))[:55], ok)
1422
+ console.print(t); console.print()
1423
+
1424
+ if report.get("confidence_note"):
1425
+ console.print(Panel(f"[dim]{report['confidence_note']}[/dim]",
1426
+ title="Caveats", border_style="dim"))
1427
+ console.rule("[dim]End of Report — v4[/dim]")
1428
+
1429
+
1430
+ def _render_plain(state: ReviewState):
1431
+ S = "="*72
1432
+ def sec(t): print(f"\n{S}\n {t}\n{S}")
1433
+ r = state.get("report",{}); st = state.get("stats",{})
1434
+ se = state.get("sentiment",{}); q = state.get("user_query","")
1435
+ sec("REVIEW INTELLIGENCE REPORT v4")
1436
+ if q:
1437
+ print(f"\nQuery : {q}")
1438
+ print(f"Answer: {r.get('direct_answer','(see summary)')}")
1439
+ print(f"\nSummary : {r.get('executive_summary','')}")
1440
+ print(f"Sentiment: {se.get('overall_tone','?')} [{se.get('method','?')}]")
1441
+ print(f"Provider : {_PROVIDER_TAG}")
1442
+ sec("METRICS")
1443
+ for k,v in [("Reviews",st.get("total_reviews")),("Avg",st.get("avg_rating")),
1444
+ ("Pos%",se.get("pct_positive")),("Neg%",se.get("pct_negative"))]:
1445
+ print(f" {k:<12}: {v}")
1446
+ sec("CLUSTERS")
1447
+ for c in state.get("clusters",[])[:8]:
1448
+ print(f" [{c.get('type','?')}] {c.get('label','?')} sev={c.get('severity','?')}")
1449
+ sec("PROBLEMS")
1450
+ for i,p in enumerate(r.get("top_problems",[]),1):
1451
+ print(f" #{i} [{p.get('severity','')}] {p.get('issue','')}")
1452
+ print(f" {p.get('evidence','')[:100]}")
1453
+ sec("RECOMMENDATIONS")
1454
+ for i,rec in enumerate(r.get("recommendations",[]),1):
1455
+ print(f" #{i} [{rec.get('priority','')}] {rec.get('action','')}")
1456
+ print(f"\n{S}\n End\n{S}\n")
1457
+
1458
+
1459
+ # ══════════════════════════════════════════════════════════════════════════════
1460
+ # LOGGING
1461
+ # ══════════════════════════════════════════════════════════════════════════════
1462
+
1463
+ def _log(msg: str, level: str = "info"):
1464
+ ts = datetime.now().strftime("%H:%M:%S")
1465
+ icons = {"info":"·","ok":"✓","warn":"⚠","err":"✗","agent":"▶"}
1466
+ icon = icons.get(level,"·")
1467
+ if RICH:
1468
+ colors = {"info":"dim white","ok":"green","warn":"yellow","err":"red","agent":"cyan"}
1469
+ console.print(f"[{colors.get(level,'white')}][{ts}] {icon} {msg}[/{colors.get(level,'white')}]")
1470
+ else:
1471
+ print(f"[{ts}] {icon} {msg}")
1472
+
1473
+
1474
+ # ══════════════════════════════════════════════════════════════════════════════
1475
+ # ENTRY POINT
1476
+ # ══════════════════════════════════════════════════════════════════════════════
1477
+
1478
+ def main():
1479
+ parser = argparse.ArgumentParser(
1480
+ description="Review Intelligence Agent v4 — LangGraph + LangChain",
1481
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1482
+ epilog=textwrap.dedent("""
1483
+ Environment variables:
1484
+ GROQ_API_KEY Primary LLM (free tier)
1485
+ OPENROUTER_API_KEY Fallback #1
1486
+ NVIDIA_API_KEY Fallback #2 (NIMs)
1487
+ PINECONE_API_KEY Pinecone integrated embedding (no OpenAI needed)
1488
+ PINECONE_INDEX Index name (default: review-agent-v4)
1489
+ HF_CODE_AGENT_URL Deployed HuggingFace Space for pandas code execution
1490
+
1491
+ NLP:
1492
+ DistilBERT runs locally (free, no API).
1493
+ pip install transformers torch to enable it.
1494
+ Falls back to rating-heuristic if not installed.
1495
+
1496
+ Examples:
1497
+ python review_agent_v4.py --csv reviews.csv
1498
+ python review_agent_v4.py --csv reviews.csv \\
1499
+ --query "Which action game has the most ad complaints?"
1500
+ python review_agent_v4.py --csv reviews.csv --use-pinecone \\
1501
+ --query "Show 1-star reviews for com.JindoBlu app"
1502
+ """),
1503
+ )
1504
+ parser.add_argument("--csv", required=True)
1505
+ parser.add_argument("--query", default="",
1506
+ help="Natural language question about the reviews")
1507
+ parser.add_argument("--use-pinecone",action="store_true",
1508
+ help="Enable Pinecone RAG (requires PINECONE_API_KEY)")
1509
+ parser.add_argument("--save-json", action="store_true")
1510
+ args = parser.parse_args()
1511
+
1512
+ if not os.path.exists(args.csv):
1513
+ print(f"File not found: {args.csv}"); sys.exit(1)
1514
+
1515
+ _log(f"LLM: {_PROVIDER_TAG}", "info")
1516
+ _log(f"BERT: {'DistilBERT (local)' if HAS_TRANSFORMERS else 'unavailable (pip install transformers torch)'}", "info")
1517
+ if args.use_pinecone and not HAS_PINECONE:
1518
+ _log("pinecone not installed — RAG disabled (pip install pinecone)", "warn")
1519
+
1520
+ graph = build_graph()
1521
+ init = _empty_state(args.csv, args.query.strip(),
1522
+ args.use_pinecone and HAS_PINECONE)
1523
+ t0 = time.time()
1524
+ final = graph.invoke(init)
1525
+ _log(f"Pipeline complete in {round(time.time()-t0,1)}s "
1526
+ f"(planner iters: {final.get('planner_iter',0)})", "ok")
1527
+
1528
+ render(final)
1529
+
1530
+ if args.save_json:
1531
+ out = str(Path(args.csv).with_suffix("")) + "_report_v4.json"
1532
+ safe = {k: v for k, v in final.items() if k != "raw_df"}
1533
+ with open(out, "w", encoding="utf-8") as f:
1534
+ json.dump(safe, f, indent=2, default=str)
1535
+ _log(f"JSON saved → {out}", "ok")
1536
+
1537
+ if final.get("errors"):
1538
+ _log(f"Non-fatal errors: {final['errors']}", "warn")
1539
+
1540
+
1541
+ def run_agent(query: str, csv_path: Optional[str] = None, df: Optional[pd.DataFrame] = None, use_pinecone: bool = False) -> dict:
1542
+ """Entry point for the web app or other modules."""
1543
+ graph = build_graph()
1544
+ state = _empty_state(csv_path or "", query.strip(), use_pinecone and HAS_PINECONE)
1545
+
1546
+ if df is not None:
1547
+ state["raw_df"] = df
1548
+
1549
+ return graph.invoke(state)
1550
+
1551
+
1552
+ if __name__ == "__main__":
1553
+ main()