I just attempted a scrape with a link - it failed on the app end. Firecrawl worked fine from looking at my dashboard. Can you inspect console logs
cat /private/tmp/claude-501/-Users-shaynelarocque-Documents-GitHub-futuresight/5c8423a6-db28-4eb0-98eb-ccb8759929ff/tasks/blgyc1k54.output
INFO: Will watch for changes in these directories: ['/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens'] INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) INFO: Started reloader process [61666] using WatchFiles INFO: Started server process [61668] INFO: Waiting for application startup. INFO: Application startup complete. INFO: 127.0.0.1:58417 - "GET /health HTTP/1.1" 200 OK WARNING: WatchFiles detected changes in 'app/tools/presentation_tools.py'. Reloading... INFO: Shutting down INFO: Waiting for application shutdown. INFO: Application shutdown complete. INFO: Finished server process [61668] INFO: Started server process [61916] INFO: Waiting for application startup. INFO: Application startup complete. WARNING: WatchFiles detected changes in 'app/tools/presentation_tools.py'. Reloading... INFO: Shutting down INFO: Waiting for application shutdown. INFO: Application shutdown complete. INFO: Finished server process [61916] INFO: Started server process [61994] INFO: Waiting for application startup. INFO: Application startup complete. WARNING: WatchFiles detected changes in 'app/prompts.py'. Reloading... INFO: Shutting down INFO: Waiting for application shutdown. INFO: Application shutdown complete. INFO: Finished server process [61994] INFO: Started server process [61996] INFO: Waiting for application startup. INFO: Application startup complete. WARNING: WatchFiles detected changes in 'app/prompts.py'. Reloading... INFO: Shutting down INFO: Waiting for application shutdown. INFO: Application shutdown complete. INFO: Finished server process [61996] INFO: Started server process [62831] INFO: Waiting for application startup. INFO: Application startup complete. INFO: 127.0.0.1:58963 - "GET /chat/b7a6e9e5-cc9d-4c2b-b383-0fc9e88ec475 HTTP/1.1" 200 OK ... 75 more lines (115 total - use Read tool for full file)
cat /private/tmp/claude-501/-Users-shaynelarocque-Documents-GitHub-futuresight/5c8423a6-db28-4eb0-98eb-ccb8759929ff/tasks/blgyc1k54.output | tail -60
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
Task exception was never retrieved
future: <Task finished name='Task-17' coro=<_run_scrape() done, defined at /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py:288> exception=NameError("name '_emit' is not defined")>
Traceback (most recent call last):
File "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py", line 304, in _run_scrape
await _emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
^^^^^
NameError: name '_emit' is not defined. Did you mean: 'emit'?
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py", line 308, in _run_scrape
await _emit(session_id, f"Scraping failed: {e}", "error")
^^^^^
NameError: name '_emit' is not defined. Did you mean: 'emit'?
INFO: 127.0.0.1:59052 - "GET /api/status/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /chat/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OK
INFO: 127.0.0.1:59052 - "GET /static/app.js HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:59111 - "GET /chat/978604e9-9788-4093-aa6f-e3782af0df81 HTTP/1.1" 200 OKFound it. During the refactor, _run_scrape still references _emit instead of the renamed emit import.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 285,
"limit": 30
} 285→ )
286→
287→
288→async def _run_scrape(session_id: str, url: str, product_name: str, platform: str):
289→ """Background task: scrape URL, index reviews, update session status."""
290→ try:
291→ reviews = await scrape_url(url, platform=platform)
292→
293→ if not reviews:
294→ store.set_status(session_id, "error")
295→ await _emit(session_id, "No reviews could be extracted from that URL.", "error")
296→ return
297→
298→ summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
299→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
300→ indexed = vectordb.index_reviews(session_id, reviews)
301→ summary.total_reviews = indexed
302→ store.update_summary(session_id, summary)
303→ store.set_status(session_id, "ready")
304→ await _emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
305→
306→ except Exception as e:
307→ store.set_status(session_id, "error")
308→ await _emit(session_id, f"Scraping failed: {e}", "error")
309→
310→
311→# ── Session status polling (for scraping progress) ───────────────────
312→
313→@app.get("/api/status/{session_id}")
314→async def get_status(session_id: str):await _emit(
await emit(
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated. All occurrences were successfully replaced.
{
"pattern": "_emit|_get_queue|_get_response_event|_render_message",
"path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"output_mode": "content"
}Fixed. The _run_scrape function had three _emit references that weren't caught during the refactor rename to emit. The scrape completed on Firecrawl's end but failed when trying to emit the success/error SSE event back to the app.
I just ran a scrape and it worked.
However, it did the old style where it didnt run an initial analysis - it said:
"I have 50 reviews loaded for HL2 from Web, spanning 2025-10-17 to 2026-03-20.
I can only answer questions grounded in this dataset. What would you like to explore?"
Does url scrape + csv upload trigger the initial analysis like the sample data does? That has been my primary testing flow.
Good catch. Let me check the scrape flow — it's different from upload/sample because it runs in the background.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 270,
"limit": 45
} 270→ session_id=session_id,
271→ product_name=product_name or "Unknown Product",
272→ platform=platform or "Web",
273→ status="scraping",
274→ )
275→ store.save_session(session)
276→
277→ # Run Firecrawl agent in background — it can take minutes
278→ asyncio.create_task(_run_scrape(session_id, url, product_name, platform))
279→
280→ # Redirect to chat page which shows a progress view
281→ return HTMLResponse(
282→ status_code=200,
283→ content=f'<script>window.location.href="/chat/{session_id}";</script>',
284→ headers={"HX-Redirect": f"/chat/{session_id}"},
285→ )
286→
287→
288→async def _run_scrape(session_id: str, url: str, product_name: str, platform: str):
289→ """Background task: scrape URL, index reviews, update session status."""
290→ try:
291→ reviews = await scrape_url(url, platform=platform)
292→
293→ if not reviews:
294→ store.set_status(session_id, "error")
295→ await emit(session_id, "No reviews could be extracted from that URL.", "error")
296→ return
297→
298→ summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
299→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
300→ indexed = vectordb.index_reviews(session_id, reviews)
301→ summary.total_reviews = indexed
302→ store.update_summary(session_id, summary)
303→ store.set_status(session_id, "ready")
304→ await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
305→
306→ except Exception as e:
307→ store.set_status(session_id, "error")
308→ await emit(session_id, f"Scraping failed: {e}", "error")
309→
310→
311→# ── Session status polling (for scraping progress) ───────────────────
312→
313→@app.get("/api/status/{session_id}")
314→async def get_status(session_id: str):There it is — _run_scrape sets status to "ready" and emits a success message, but never calls _trigger_auto_analysis. Upload and sample both have it, scrape doesn't.
store.set_status(session_id, "ready")
await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
except Exception as e: store.set_status(session_id, "ready")
await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
# Kick off auto-analysis
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)
except Exception as e:The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
Fixed. The scrape background task now triggers auto-analysis after indexing, same as upload and sample. The page will reload (via the existing scraping status poller) and pick up the auto-analysis SSE stream.
{
"pattern": "save_reviews_json|reviews\\.json",
"path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app",
"output_mode": "content"
}app/main.py:173: store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews]) app/main.py:238: store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews]) app/main.py:299: store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews]) app/store.py:83:def save_reviews_json(session_id: str, reviews: list[dict]) -> None: app/store.py:84: path = _session_dir(session_id) / "reviews.json" app/store.py:89: path = _session_dir(session_id) / "reviews.json"
Reviews are already stored as JSON. I'll add an endpoint that converts to CSV and a download button in the sidebar dataset section.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 311,
"limit": 15
} 311→ except Exception as e:
312→ store.set_status(session_id, "error")
313→ await emit(session_id, f"Scraping failed: {e}", "error")
314→
315→
316→# ── Session status polling (for scraping progress) ───────────────────
317→
318→@app.get("/api/status/{session_id}")
319→async def get_status(session_id: str):
320→ session = store.load_session(session_id)
321→ if not session:
322→ return JSONResponse({"status": "not_found"}, status_code=404)
323→ return JSONResponse({"status": session.status})
324→
325→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 1,
"limit": 25
} 1→"""FastAPI application for ReviewLens AI."""
2→
3→from __future__ import annotations
4→
5→import asyncio
6→import os
7→import uuid
8→from pathlib import Path
9→
10→from dotenv import load_dotenv
11→from fastapi import FastAPI, File, Form, Request, UploadFile
12→from fastapi.responses import HTMLResponse, JSONResponse
13→from fastapi.staticfiles import StaticFiles
14→from fastapi.templating import Jinja2Templates
15→from sse_starlette.sse import EventSourceResponse
16→
17→from . import knowledge, store, vectordb
18→from .agent import handle_message
19→from .ingest import build_summary, parse_csv, scrape_url
20→from .models import ChatMessage, Session
21→from .rendering import render_message, render_message_filter
22→from .sse import emit, get_queue, get_response_event
23→
24→load_dotenv()
25→import asyncio import os import uuid from pathlib import Path from dotenv import load_dotenv from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse
import asyncio import csv import io import os import uuid from pathlib import Path from dotenv import load_dotenv from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
return JSONResponse({"status": session.status})
# ── Archive (delete) session return JSONResponse({"status": session.status})
# ── CSV download ─────────────────────────────────────────────────────
@app.get("/api/sessions/{session_id}/csv")
async def download_csv(session_id: str):
"""Download the session's reviews as a CSV file."""
session = store.load_session(session_id)
if not session:
return JSONResponse({"error": "Session not found"}, status_code=404)
reviews = store.load_reviews_json(session_id)
if not reviews:
return JSONResponse({"error": "No reviews found"}, status_code=404)
# Collect all metadata keys across reviews for columns
meta_keys: list[str] = []
seen: set[str] = set()
for r in reviews:
for k in r.get("metadata", {}):
if k not in seen:
seen.add(k)
meta_keys.append(k)
buf = io.StringIO()
writer = csv.writer(buf)
# Header
columns = ["text"] + meta_keys
writer.writerow(columns)
# Rows
for r in reviews:
meta = r.get("metadata", {})
row = [r.get("text", "")]
for k in meta_keys:
row.append(meta.get(k, ""))
writer.writerow(row)
buf.seek(0)
filename = f"{session.product_name or 'reviews'}.csv".replace(" ", "_")
return StreamingResponse(
buf,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── Archive (delete) sessionThe file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/app.html",
"offset": 46,
"limit": 30
} 46→
47→ {% if session %}
48→ <!-- Active session summary -->
49→ <div class="sidebar-section">
50→ <div class="section-label">Current Dataset</div>
51→ <div class="active-dataset">
52→ <div class="dataset-name">{{ summary.product_name }}</div>
53→ <div class="dataset-meta">
54→ <span class="platform-tag">{{ summary.platform }}</span>
55→ <span class="dataset-stat">{{ summary.total_reviews }} reviews</span>
56→ {% if summary.average_rating %}
57→ <span class="dataset-stat">{{ "%.1f"|format(summary.average_rating) }} avg</span>
58→ {% endif %}
59→ </div>
60→ {% if summary.rating_distribution %}
61→ <div class="mini-dist">
62→ {% for star in ['5','4','3','2','1'] %}
63→ {% set count = summary.rating_distribution.get(star, 0) %}
64→ {% set pct = (count / summary.total_reviews * 100) if summary.total_reviews else 0 %}
65→ <div class="mini-dist-row">
66→ <span class="mini-dist-label">{{ star }}</span>
67→ <div class="mini-dist-track">
68→ <div class="mini-dist-fill" style="--w: {{ pct }}%"></div>
69→ </div>
70→ <span class="mini-dist-count">{{ count }}</span>
71→ </div>
72→ {% endfor %}
73→ </div>
74→ {% endif %}
75→ </div> {% endif %}
</div>
</div>
{% endif %} {% endif %}
<a href="/api/sessions/{{ session.session_id }}/csv" class="dataset-download" download>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download CSV
</a>
</div>
</div>
{% endif %}The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/app.html has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css",
"offset": 1,
"limit": 5
} 1→/* ═══════════════════════════════════════════════════════════════════
2→ ReviewLens AI
3→ ═══════════════════════════════════════════════════════════════════ */
4→
5→*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }{
"pattern": "\\.active-dataset",
"path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css",
"output_mode": "content",
"-A": 10
}241:.active-dataset {
242- display: flex;
243- flex-direction: column;
244- gap: 0.4rem;
245-}
246-
247-.dataset-name {
248- font-size: 0.88rem;
249- font-weight: 500;
250- word-break: break-word;
251- line-height: 1.3;{
"pattern": "\\.mini-dist-count",
"path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css",
"output_mode": "content",
"-A": 8
}317:.mini-dist-count {
318- font-family: var(--mono);
319- font-size: 0.6rem;
320- width: 1.4rem;
321- text-align: right;
322- color: var(--text-3);
323-}
324-
325-/* Session list *//* Session list */
.dataset-download {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
font-family: var(--mono);
color: var(--text-2);
text-decoration: none;
margin-top: 0.15rem;
transition: color 0.15s;
}
.dataset-download:hover {
color: var(--accent);
}
/* Session list */The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/style.css has been updated successfully.
python3 -c "import ast; ast.parse(open('app/main.py').read()); print('OK')"Done. Added:
GET /api/sessions/{session_id}/csv — Converts the stored reviews JSON to CSV with all metadata columns, returns as a downloadable file named {product_name}.csvClick view data on a chart snaps the scroll to the bottom - needs fix.
HL2 — Initial Intelligence Briefing Metacritic PC User Reviews · 50 Reviews Analysed 1. Dataset Overview Attribute Value Total Reviews 50 Numeric Ratings Available None — text-only corpus Avg. Review Length 211 characters (reviews skew short) Languages English, Spanish (~5), Turkish (~2), Italian (~1) Usable Reviews 48 (2 empty duplicate stubs excluded)
With no numeric ratings in the dataset, sentiment has been inferred from review text. The corpus is heavily skewed toward enthusiastic endorsement — the inferred split is visualised below.
Reviews Mentioning
Revolutionary / All-Time Great 20 Physics & Gravity Gun 15 Story / Narrative 12 World-Building & Atmosphere 6 Graphics / Art Direction 5 Timeless Longevity 5
The 86% positive reading reflects a reviewer base that is largely self-selected fans or those returning to the game after years away. The 8% negative minority is small but notably vocal and specific.
The most frequently praised aspects, ranked by mention volume across the 50 reviews:
[chart:2] 2. Top 3 Praised Aspects 🥇 Physics Engine & the Gravity Gun
Cited in ≥15 reviews, the Source Engine physics are the single most praised technical achievement. Reviewers don't just call them impressive — they repeatedly note that modern games still haven't matched them:
"The physics are something most modern games just don't bother with." [source]
"The gravity gun adds a unique layer to gameplay, making every encounter creative and dynamic." [source]
"Physics that even simulate weight, this game was REVOLUTIONARY for his time." [source]
🥈 Storytelling, Characters & Pacing
Mentioned substantively in ~12 reviews, HL2's narrative delivery — no cutscenes, seamless world-building — draws consistent praise:
"The story is gripping, the characters are memorable, and the pacing keeps you hooked from start to finish." [source]
"It delivers an unforgettable blend of storytelling, physics, and world-building that still feels ahead of its time." [source]
"It blends intense action with clever puzzles and a gripping narrative, all experienced seamlessly without cutscenes." [source]
🥉 Revolutionary Legacy & Timeless Longevity
The broadest theme, appearing in ~20 reviews — reviewers frame HL2 not merely as a good game but as a civilisational landmark. Notably, this appears across multiple languages:
"Revolution we never had since. Half-Life 2 changed the whole gaming for years to come." [source]
"Still one of the best games ever created that holds up well despite being over 20 years old." [source]
"Valve does so much in here with so little that it's just incredible." [source]
A vocal minority dismisses the consensus outright. These reviews tend to be brief and lack specific critique — they read more as cultural pushback than gameplay analysis:
"This game is very overrated. You just kinda run around and shoot combine for 10 hours." [source]
"This game is overrated af. Why people reacting like impossible thing is happened." [source]
At 8% of reviews, this is an emerging pattern by the framework threshold — not yet a dominant signal, but worth tracking. ⚠️ #2 — Second-Half Pacing Collapse (1 detailed review + corroboration)
The corpus's most substantive critique comes from [source] (757 chars — the longest critical review). It argues the game deteriorates after Nova Prospekt:
"Starting with Nova Prospekt, it throws multiple holdout events which aren't so enjoyable on repeat. Those strider battles near the end where you just hug an RPG crate... Episode One is basically what the 2nd half of this game is, but if it was solid throughout."
This is a single review, but its specificity and length distinguish it from surface-level dismissals. ⚠️ #3 — Sound Design & Difficulty Spikes (1 review)
[source] — the newest review in the corpus (self-dated 2026) — targets audio fatigue and artificial difficulty:
"The sound design is awful — constant, grating alien noises and nonstop gunfire that never lets up. The helicopter fights are stupidly hard in the worst way, not challenging, just frustrating."
Based on 1 review — treat as an isolated data point, but read alongside the generational friction signal below. 4. Risk Signals & Notable Flags
🔴 HIGH — Duplicate Stub Reviews review_13 and review_35 are byte-for-byte identical: both read [SPOILER ALERT: This review contains spoilers.] with zero content. These are hollow submissions — either platform bugs or abandoned drafts. They must be excluded from any weighted analysis and should be flagged to the data ingestion pipeline.
🟡 MEDIUM — Generational Friction (Emerging, 2026) The most recent review (review_22) explicitly frames HL2 as a "museum piece people pretend is still fun." This is currently 1/50 — an anecdote, not a signal. However, it represents a distinct voice: a new player encountering the game cold in 2026, without nostalgia as scaffolding. If the platform continues to receive reviews from this demographic, this framing could grow.
🟡 MEDIUM — Half-Life 3 Demand Bleed-Through Multiple reviewers across English and Spanish are venting HL3 frustration within HL2 reviews: "please valve release half life 3" [source]; "Valve saca Half-Life 3 Por favor" [source]. This isn't a complaint about HL2 itself — it's franchise sentiment leaking into product reviews, which can distort sentiment scoring.
🟢 LOW — Multilingual Blind Spot (~16% of corpus) 8 reviews in Spanish, Turkish, and Italian are included in theme extraction but their nuance is not fully captured. Spanish reviewers appear uniformly positive and match the English sentiment profile. Full translation would be required to confirm this for ORM reporting purposes. 5. Overall Sentiment Assessment
The overwhelming verdict: reverence. Half-Life 2's Metacritic corpus is not the profile of a product being actively discovered — it's a legacy archive. Reviewers arrive already knowing its reputation and largely confirm it. The praise vocabulary ("masterpiece," "revolutionary," "greatest of all time") is superlative and consistent across languages and review lengths.
The critical minority (~8%) divides into two distinct types: cultural contrarians (brief, imprecise "overrated" dismissals) and nuanced critics (specific gameplay concerns about pacing and replay value). Only the latter carries analytical weight for product feedback purposes.
The one credible emerging risk is the generational turnover signal: as the nostalgia cohort ages out and cold-experience reviewers grow, the sentiment floor may drift. At 1/50, it's a watch item — not an action item.
Key findings have been saved to the report. Ask to generate the full report at any time. Reviews Positive / Enthusiastic 43 Negative / Critical 4 Mixed / Nuanced 1 Empty / Unusable 2
List Knowledge Files Knowledge library: 3 files available Calculate Stats Calculated stats: rating_distribution operation: rating_distribution Calculate Stats Calculated stats: summary_stats operation: summary_stats Extract Themes Extracted 15 themes from 50 reviews (focus: praise) Extract Themes Extracted 15 themes from 50 reviews (focus: complaints) Find Anomalies Anomaly scan complete: 2 categories flagged Search Reviews Broadened search: "gameplay mechanics physics combat excellent" — 1 variants, 10 unique results (strong) query: gameplay mechanics physics combat excellent Search Reviews Broadened search: "story narrative atmosphere world immersive" — 1 variants, 10 unique results (strong) query: story narrative atmosphere world immersive Search Reviews Broadened search: "graphics visuals aged outdated poor" — 1 variants, 10 unique results (strong) query: graphics visuals aged outdated poor Search Reviews Broadened search: "boring repetitive slow disappointing problems issues" — 1 variants, 10 unique results (strong) query: boring repetitive slow disappointing problems issues Read Knowledge File Read knowledge file: review-analysis-framework (3383 chars) name: review-analysis-framework Search Reviews Broadened search: "gravity gun innovative revolutionary weapon unique" — 1 variants, 8 unique results (moderate) query: gravity gun innovative revolutionary weapon unique Search Reviews Broadened search: "overrated museum piece boring pacing second half slow" — 1 variants, 10 unique results (strong) query: overrated museum piece boring pacing second half slow Search Reviews Searched reviews: "half life 3 sequel valve" — 8 results query: half life 3 sequel valve Analyze Sentiment Analysing sentiment: "half life 2 overall review experience" — 30 reviews query: half life 2 overall review experience
Good data flowing in. Let me run targeted searches for the gravity gun, pacing/second-half complaints, Half-Life 3 demand, and broad sentiment analysis — all in parallel. Generate Chart Generated chart: HL2 Metacritic Review Sentiment Distribution (50 Reviews) chart_type: doughnut · title: HL2 Metacritic Review Sentiment Distribution (50 Reviews) Generate Chart Generated chart: Most-Cited Praise Aspects (review count, n=50) chart_type: horizontalBar · title: Most-Cited Praise Aspects (review count, n=50) Save To Report Saved finding to report: dataset_overview section: dataset_overview Save To Report Saved finding to report: key_findings section: key_findings Save To Report Saved finding to report: risk_signals section: risk_signals Save To Report Saved finding to report: executive_summary section: executive_summary Suggest Follow Ups Suggested 4 follow-up questions
Excellent data gathered. Now generating the charts and saving key findings — all in parallel.
This initial analysis had an issue rendering chart 2 - why? where did it break
Two issues:
Scroll snap — The MutationObserver on chatMessages calls scrollToBottom on any DOM change, including toggling chart data visibility.
[chart:2] not rendering — The agent generated 2 charts (index 0 and 1) but referenced [chart:2] in its text. The marker wasn't replaced because there's no chart at that index. Need to strip unmatched markers rather than leaving them as raw text.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js",
"offset": 520,
"limit": 10
} 520→ type: chartType,
521→ data: { labels: config.data.labels, datasets: datasets },
522→ options: chartOptions,
523→ });
524→}
525→
526→function runChartScripts() {
527→ if (!chatMessages) return;
528→ var scripts = chatMessages.querySelectorAll("script");
529→ scripts.forEach(function (script) {{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js",
"offset": 544,
"limit": 20
} 544→ } else {
545→ el.style.display = "none";
546→ if (btn && btn.classList.contains("chart-data-toggle")) btn.textContent = "View data";
547→ }
548→}
549→
550→// ── Utilities ────────────────────────────────────────────────────────
551→
552→function escapeHtml(str) {
553→ var div = document.createElement("div");
554→ div.appendChild(document.createTextNode(str));
555→ return div.innerHTML;
556→}
557→
558→if (chatMessages) {
559→ var observer = new MutationObserver(scrollToBottom);
560→ observer.observe(chatMessages, { childList: true, subtree: true });
561→}
562→
563→scrollToBottom();The observer watches subtree: true — any attribute or style change triggers scrollToBottom. Fix: only scroll on childList additions (new messages), not subtree mutations.
if (chatMessages) {
var observer = new MutationObserver(scrollToBottom);
observer.observe(chatMessages, { childList: true, subtree: true });
}if (chatMessages) {
var observer = new MutationObserver(function (mutations) {
// Only auto-scroll when new child nodes are added (new messages),
// not when existing elements change (e.g. toggling chart data)
for (var i = 0; i < mutations.length; i++) {
if (mutations[i].type === "childList" && mutations[i].addedNodes.length > 0) {
scrollToBottom();
return;
}
}
});
observer.observe(chatMessages, { childList: true, subtree: true });
}The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/static/app.js has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/rendering.py",
"offset": 95,
"limit": 25
} 95→ def _replace(match):
96→ idx = int(match.group(1))
97→ if idx < len(charts) and idx not in placed:
98→ placed.add(idx)
99→ return _render_chart_html(charts[idx])
100→ return match.group(0)
101→
102→ html = re.sub(r'\[chart:(\d+)\]', _replace, html)
103→
104→ # Also handle markers that survived markdown (wrapped in <p> tags)
105→ def _replace_wrapped(match):
106→ idx = int(match.group(1))
107→ if idx < len(charts) and idx not in placed:
108→ placed.add(idx)
109→ return _render_chart_html(charts[idx])
110→ return match.group(0)
111→
112→ html = re.sub(r'<p>\[chart:(\d+)\]</p>', _replace_wrapped, html)
113→
114→ leftovers = [c for i, c in enumerate(charts) if i not in placed]
115→ return html, leftovers
116→
117→
118→def render_message(msg: ChatMessage) -> str:
119→ """Render a ChatMessage as HTML string.The return match.group(0) lines leave unmatched markers as raw text. Change them to return empty string.
def _replace(match):
idx = int(match.group(1))
if idx < len(charts) and idx not in placed:
placed.add(idx)
return _render_chart_html(charts[idx])
return match.group(0)
html = re.sub(r'\[chart:(\d+)\]', _replace, html)
# Also handle markers that survived markdown (wrapped in <p> tags)
def _replace_wrapped(match):
idx = int(match.group(1))
if idx < len(charts) and idx not in placed:
placed.add(idx)
return _render_chart_html(charts[idx])
return match.group(0)
html = re.sub(r'<p>\[chart:(\d+)\]</p>', _replace_wrapped, html) def _replace(match):
idx = int(match.group(1))
if idx < len(charts) and idx not in placed:
placed.add(idx)
return _render_chart_html(charts[idx])
return "" # Strip unmatched markers
html = re.sub(r'\[chart:(\d+)\]', _replace, html)
# Also handle markers that survived markdown (wrapped in <p> tags)
def _replace_wrapped(match):
idx = int(match.group(1))
if idx < len(charts) and idx not in placed:
placed.add(idx)
return _render_chart_html(charts[idx])
return "" # Strip unmatched markers
html = re.sub(r'<p>\[chart:(\d+)\]</p>', _replace_wrapped, html)The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/rendering.py has been updated successfully.
Two fixes:
Scroll snap — MutationObserver now only calls scrollToBottom when new child nodes are added (new messages arriving), not when existing elements change (toggling chart data visibility).
Unmatched [chart:N] markers — Now stripped from output instead of rendered as raw text. The agent miscounted (referenced [chart:2] but only generated charts 0 and 1) — the marker disappears cleanly rather than showing to the user.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 155,
"limit": 30
} 155→ status_code=400,
156→ )
157→
158→ if not reviews:
159→ return templates.TemplateResponse(
160→ "partials/error.html",
161→ {"request": request, "error": "No reviews found in the CSV. Make sure it has a text/review column."},
162→ status_code=400,
163→ )
164→
165→ summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform)
166→
167→ session = Session(
168→ session_id=session_id,
169→ product_name=summary.product_name,
170→ platform=summary.platform,
171→ summary=summary,
172→ status="indexing",
173→ )
174→ store.save_session(session)
175→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
176→
177→ indexed = vectordb.index_reviews(session_id, reviews)
178→ summary.total_reviews = indexed
179→ store.update_summary(session_id, summary)
180→ store.set_status(session_id, "ready")
181→
182→ # Kick off auto-analysis
183→ session = store.load_session(session_id)
184→ if session:{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/templates/app.html",
"offset": 82,
"limit": 15
} 82→ </div>
83→ {% endif %}
84→
85→ <!-- Chat history -->
86→ <div class="sidebar-section sidebar-history">
87→ <div class="section-label">History</div>
88→ <nav class="session-list">
89→ {% for s in sessions %}
90→ <div class="session-item-wrap">
91→ <a href="/chat/{{ s.session_id }}"
92→ class="session-item{% if active_id == s.session_id %} active{% endif %}">
93→ <span class="session-name">{{ s.product_name or 'Untitled' }}</span>
94→ <span class="session-detail">{{ s.summary.total_reviews }} reviews · {{ s.platform }}</span>
95→ </a>
96→ <div class="session-menu-wrap">The sidebar currently shows s.product_name which comes from the user's form input or filename. I'll add a Haiku call after ingestion to generate a concise workspace name from a sample of the reviews, then store it on the session.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 1,
"limit": 25
} 1→"""FastAPI application for ReviewLens AI."""
2→
3→from __future__ import annotations
4→
5→import asyncio
6→import csv
7→import io
8→import os
9→import uuid
10→from pathlib import Path
11→
12→from dotenv import load_dotenv
13→from fastapi import FastAPI, File, Form, Request, UploadFile
14→from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
15→from fastapi.staticfiles import StaticFiles
16→from fastapi.templating import Jinja2Templates
17→from sse_starlette.sse import EventSourceResponse
18→
19→from . import knowledge, store, vectordb
20→from .agent import handle_message
21→from .ingest import build_summary, parse_csv, scrape_url
22→from .models import ChatMessage, Session
23→from .rendering import render_message, render_message_filter
24→from .sse import emit, get_queue, get_response_event
25→{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 55,
"limit": 40
} 55→
56→
57→def _trigger_auto_analysis(session_id: str, session: Session) -> None:
58→ """Save a synthetic message and kick off the initial analysis agent run."""
59→ trigger_msg = ChatMessage(
60→ role="user",
61→ content="[initial_analysis]",
62→ system_initiated=True,
63→ )
64→ store.append_message(session_id, trigger_msg)
65→ get_response_event(session_id).clear()
66→ asyncio.create_task(
67→ _run_agent_and_respond(session_id, _INITIAL_ANALYSIS_PROMPT, session)
68→ )
69→
70→
71→# ── Health check ─────────────────────────────────────────────────────
72→
73→@app.get("/health")
74→async def health():
75→ return {"status": "ok"}
76→
77→
78→# ── Sample data discovery ────────────────────────────────────────────
79→
80→SAMPLE_DIR = BASE_DIR / "sample-data"
81→
82→
83→def _list_sample_files() -> list[dict[str, str]]:
84→ """List available sample CSVs with human-readable labels."""
85→ if not SAMPLE_DIR.exists():
86→ return []
87→ files = []
88→ for f in sorted(SAMPLE_DIR.glob("*.csv")):
89→ label = f.stem.replace("_", " ").replace("-", " ").title()
90→ files.append({"filename": f.name, "label": label})
91→ return files
92→
93→
94→# ── App shell (home + chat share the same layout) ───────────────────I'll add a helper that calls Haiku to name the workspace, then call it after ingestion in all three flows.
def _trigger_auto_analysis(session_id: str, session: Session) -> None:
async def _generate_workspace_name(session_id: str, reviews: list) -> None:
"""Use Haiku to generate a concise workspace name from review samples."""
try:
import anthropic
sample_texts = [r.get("text", r.text if hasattr(r, "text") else "")[:150] for r in reviews[:8]]
sample_block = "\n---\n".join(sample_texts)
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
resp = await client.messages.create(
model="claude-haiku-4-5",
max_tokens=30,
messages=[{
"role": "user",
"content": (
f"Based on these review samples, generate a short workspace name (2-5 words) "
f"that captures what's being reviewed. Examples: 'Bella Napoli Reviews', "
f"'AirPods Pro Feedback', 'Hilton Downtown Analysis'. Return ONLY the name.\n\n{sample_block}"
),
}],
)
name = resp.content[0].text.strip().strip('"\'')
if name and len(name) < 60:
session = store.load_session(session_id)
if session:
session.product_name = name
session.summary.product_name = name
store.save_session(session)
except Exception:
pass # Keep the original name on failure
def _trigger_auto_analysis(session_id: str, session: Session) -> None:The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 190,
"limit": 30
} 190→ if not reviews:
191→ return templates.TemplateResponse(
192→ "partials/error.html",
193→ {"request": request, "error": "No reviews found in the CSV. Make sure it has a text/review column."},
194→ status_code=400,
195→ )
196→
197→ summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform)
198→
199→ session = Session(
200→ session_id=session_id,
201→ product_name=summary.product_name,
202→ platform=summary.platform,
203→ summary=summary,
204→ status="indexing",
205→ )
206→ store.save_session(session)
207→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
208→
209→ indexed = vectordb.index_reviews(session_id, reviews)
210→ summary.total_reviews = indexed
211→ store.update_summary(session_id, summary)
212→ store.set_status(session_id, "ready")
213→
214→ # Kick off auto-analysis
215→ session = store.load_session(session_id)
216→ if session:
217→ _trigger_auto_analysis(session_id, session)
218→
219→ return HTMLResponse( indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
# Kick off auto-analysis
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── Sample Data ────────────────────────────────────────────────────── indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
# Name the workspace and kick off auto-analysis
await _generate_workspace_name(session_id, reviews)
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── Sample Data ──────────────────────────────────────────────────────The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 244,
"limit": 20
} 244→ # Derive product name and platform from the filename
245→ stem = path.stem.replace("_", " ").replace("-", " ")
246→ product_name = stem.title()
247→ platform = ""
248→ for plat in ("Amazon", "Google Maps", "G2", "Capterra", "Trustpilot", "Yelp"):
249→ if plat.lower().replace(" ", "_") in path.stem.lower() or plat.lower().replace(" ", "") in path.stem.lower():
250→ platform = plat
251→ break
252→
253→ reviews = await parse_csv(content, platform=platform, product_name=product_name)
254→
255→ if not reviews:
256→ return templates.TemplateResponse(
257→ "partials/error.html",
258→ {"request": request, "error": "Could not parse reviews from this sample file."},
259→ status_code=400,
260→ )
261→
262→ session_id = str(uuid.uuid4())
263→ summary = build_summary(reviews, source_type="csv", product_name=product_name, platform=platform){
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 270,
"limit": 20
} 270→ status="indexing",
271→ )
272→ store.save_session(session)
273→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
274→
275→ indexed = vectordb.index_reviews(session_id, reviews)
276→ summary.total_reviews = indexed
277→ store.update_summary(session_id, summary)
278→ store.set_status(session_id, "ready")
279→
280→ # Kick off auto-analysis
281→ session = store.load_session(session_id)
282→ if session:
283→ _trigger_auto_analysis(session_id, session)
284→
285→ return HTMLResponse(
286→ status_code=200,
287→ content=f'<script>window.location.href="/chat/{session_id}";</script>',
288→ headers={"HX-Redirect": f"/chat/{session_id}"},
289→ ) indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
# Kick off auto-analysis
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── URL Scrape indexed = vectordb.index_reviews(session_id, reviews)
summary.total_reviews = indexed
store.update_summary(session_id, summary)
store.set_status(session_id, "ready")
# Name the workspace and kick off auto-analysis
await _generate_workspace_name(session_id, reviews)
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)
return HTMLResponse(
status_code=200,
content=f'<script>window.location.href="/chat/{session_id}";</script>',
headers={"HX-Redirect": f"/chat/{session_id}"},
)
# ── URL ScrapeThe file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
{
"file_path": "/Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py",
"offset": 320,
"limit": 30
} 320→ headers={"HX-Redirect": f"/chat/{session_id}"},
321→ )
322→
323→
324→async def _run_scrape(session_id: str, url: str, product_name: str, platform: str):
325→ """Background task: scrape URL, index reviews, update session status."""
326→ try:
327→ reviews = await scrape_url(url, platform=platform)
328→
329→ if not reviews:
330→ store.set_status(session_id, "error")
331→ await emit(session_id, "No reviews could be extracted from that URL.", "error")
332→ return
333→
334→ summary = build_summary(reviews, source_type="url", product_name=product_name, platform=platform)
335→ store.save_reviews_json(session_id, [r.model_dump(mode="json") for r in reviews])
336→ indexed = vectordb.index_reviews(session_id, reviews)
337→ summary.total_reviews = indexed
338→ store.update_summary(session_id, summary)
339→ store.set_status(session_id, "ready")
340→ await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
341→
342→ # Kick off auto-analysis
343→ session = store.load_session(session_id)
344→ if session:
345→ _trigger_auto_analysis(session_id, session)
346→
347→ except Exception as e:
348→ store.set_status(session_id, "error")
349→ await emit(session_id, f"Scraping failed: {e}", "error") store.set_status(session_id, "ready")
await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
# Kick off auto-analysis
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session) store.set_status(session_id, "ready")
await emit(session_id, f"Scraping complete — {indexed} reviews indexed.", "info")
# Name the workspace and kick off auto-analysis
await _generate_workspace_name(session_id, reviews)
session = store.load_session(session_id)
if session:
_trigger_auto_analysis(session_id, session)The file /Users/shaynelarocque/Documents/GitHub/futuresight/reviewlens/app/main.py has been updated successfully.
python3 -c "import ast; ast.parse(open('app/main.py').read()); print('OK')"Done. Added _generate_workspace_name — calls Haiku with 8 sample review snippets to generate a 2-5 word workspace name (e.g., "Bella Napoli Reviews", "AirPods Pro Feedback"). Runs after ingestion in all three flows (upload, sample, scrape) before auto-analysis kicks off. Updates product_name on both session and summary. Falls back silently to the original name on failure.