Spaces:
Sleeping
Sleeping
Vedant Jigarbhai Mehta commited on
Commit Β·
1150f40
1
Parent(s): 0357317
sync ui and backend with main for compare feature and reframe
Browse files- README.md +292 -1
- backend/routes/clusters.py +5 -3
- backend/routes/overview.py +145 -1
- backend/routes/search.py +1 -1
- backend/services/llm_service.py +218 -45
- frontend/index.html +1 -1
- frontend/src/App.jsx +2 -0
- frontend/src/components/common/AISummary.jsx +11 -4
- frontend/src/components/layout/Sidebar.jsx +1 -1
- frontend/src/components/layout/TopNavbar.jsx +2 -1
- frontend/src/pages/Clusters.jsx +6 -0
- frontend/src/pages/Compare.jsx +235 -0
- frontend/src/pages/Embeddings.jsx +23 -11
- frontend/src/pages/Landing.jsx +2 -2
- frontend/src/pages/Network.jsx +8 -7
- frontend/src/pages/Overview.jsx +31 -11
- frontend/src/pages/Search.jsx +7 -1
- frontend/src/pages/TimeSeries.jsx +1 -1
- frontend/src/services/api.js +8 -0
- vedant-prompts.md +44 -1
README.md
CHANGED
|
@@ -7,4 +7,295 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# TheScope β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# TheScope β Tracing Narratives Across Reddit Communities
|
| 11 |
+
|
| 12 |
+
A full-stack investigative reporting dashboard analyzing how a curated set of Reddit communities discussed the 2024 US election and 2025 transition of power. Nine of the ten subreddits are explicitly political; the tenth (r/worldpolitics) is included as a documented case of unmoderated community drift.
|
| 13 |
+
|
| 14 |
+
**Live Demo**: [https://huggingface.co/spaces/mv63/thescope-dashboard](https://huggingface.co/spaces/mv63/thescope-dashboard)
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## What This Project Does
|
| 19 |
+
|
| 20 |
+
This dashboard analyzes 8,799 Reddit posts from 10 subreddits collected between July 2024 and February 2025. Nine of the subreddits span the political spectrum from far-left (r/Anarchism, r/socialism) to far-right (r/Conservative, r/Republican); the tenth (r/worldpolitics) was originally political but has drifted into largely unmoderated, off-topic content and is included as a documented case study in community drift. The dashboard combines NLP (sentence embeddings, topic clustering, semantic search) with network analysis (PageRank, betweenness centrality, Louvain community detection) to trace how narratives spread across these communities.
|
| 21 |
+
|
| 22 |
+
The research question: **How do politically diverse communities process the same events β and who bridges the divides?**
|
| 23 |
+
|
| 24 |
+
### Key Findings from the Data
|
| 25 |
+
|
| 26 |
+
- **87 bridge accounts** post in 2+ subreddits β potential cross-community influence nodes
|
| 27 |
+
- **1,500% activity surge** after inauguration (Jan 20, 2025) β avg posts/day jumped from 13 to 217
|
| 28 |
+
- **Media fragmentation**: r/Conservative shares breitbart.com (#1), r/politics shares nytimes.com (#1) β isolated information ecosystems
|
| 29 |
+
- **High-velocity accounts**: M_i_c_K posted 246 times in 26 days (9+/day) β potential automated behavior
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Features
|
| 34 |
+
|
| 35 |
+
### 1. Overview Page
|
| 36 |
+
- Key metrics (posts, authors, date range, network stats)
|
| 37 |
+
- Activity timeline with real political events annotated (Biden drops out, Election Day, Inauguration, Executive Orders spike)
|
| 38 |
+
- Subreddit distribution and top news sources shared
|
| 39 |
+
- Collapsible methodology section explaining data pipeline, NLP approach, network construction, and AI integration
|
| 40 |
+
- AI-generated executive summary
|
| 41 |
+
|
| 42 |
+
### 2. Time Series Analysis
|
| 43 |
+
- Post volume over time by subreddit (filterable, adjustable granularity: day/week/month)
|
| 44 |
+
- Average engagement score over time
|
| 45 |
+
- Topic trends over time (KMeans clusters, adjustable k)
|
| 46 |
+
- Dynamic AI-generated summaries beneath each chart
|
| 47 |
+
|
| 48 |
+
### 3. Network Analysis
|
| 49 |
+
- Interactive force-directed graph (WebGL, react-force-graph-2d)
|
| 50 |
+
- Nodes colored by Louvain community, sized by PageRank
|
| 51 |
+
- 3 edge types: crosspost links (weight 3.0), shared URL co-sharing (weight 2.0), co-subreddit activity (weight 1.0)
|
| 52 |
+
- [deleted] accounts excluded to prevent false super-connectors
|
| 53 |
+
- Click any node to inspect PageRank, betweenness, community, subreddits
|
| 54 |
+
- **Node removal simulation**: remove an account and see how the network fragments (e.g., removing John3262005 splits the network from 72 to 83 components)
|
| 55 |
+
- Min-degree filter slider
|
| 56 |
+
- AI-generated network summary
|
| 57 |
+
|
| 58 |
+
### 4. Topic Clusters
|
| 59 |
+
- KMeans clustering on 384-dim sentence embeddings
|
| 60 |
+
- Tunable cluster count (k slider: 2-50)
|
| 61 |
+
- Donut chart showing cluster proportions (clickable to expand)
|
| 62 |
+
- Expandable cluster detail: subreddit breakdown + top 10 posts with Reddit links
|
| 63 |
+
- Handles extreme k values gracefully (clamped with warning)
|
| 64 |
+
- AI-generated cluster summary
|
| 65 |
+
|
| 66 |
+
### 5. Compare Communities
|
| 67 |
+
- Side-by-side comparison of any two subreddits in the dataset
|
| 68 |
+
- Each side shows: total posts, unique authors, average score and comments, top 10 news domains, top 5 discussion topics, top 10 most active authors (clickable to Reddit), top 5 highest-scoring posts
|
| 69 |
+
- Overlapping line chart showing both communities' weekly post volume on the same axes
|
| 70 |
+
- AI-generated 4-paragraph analytical comparison covering engagement, information ecosystems, topical focus, and a journalist-ready takeaway
|
| 71 |
+
- Default comparison: r/Conservative vs r/socialism (maximum political contrast)
|
| 72 |
+
|
| 73 |
+
### 6. SearchAI (Semantic Search Chatbot)
|
| 74 |
+
- Results ranked by semantic similarity, not keyword matching
|
| 75 |
+
- Chat-style interface with conversation history
|
| 76 |
+
- Time-series chart showing matching posts over time (with day/week/month toggle)
|
| 77 |
+
- Follow-up query suggestions (LLM-generated)
|
| 78 |
+
- Clickable results link directly to Reddit posts
|
| 79 |
+
- Handles edge cases: empty input, short queries, non-English input, gibberish
|
| 80 |
+
|
| 81 |
+
### 7. Embedding Explorer
|
| 82 |
+
- Interactive Datamapplot visualization of all 8,799 posts in 2D (UMAP projection)
|
| 83 |
+
- Zoom, pan, and search within the embedding space
|
| 84 |
+
- Posts near each other discuss similar themes
|
| 85 |
+
- AI-generated 4-paragraph explanation of how to read the embedding map (for non-technical users)
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## Semantic Search: Zero Keyword Overlap Examples
|
| 90 |
+
|
| 91 |
+
The rubric requires queries with zero keyword overlap returning correct results. Here are 3 examples:
|
| 92 |
+
|
| 93 |
+
### Example 1
|
| 94 |
+
**Query**: "government overreach and civil liberties"
|
| 95 |
+
**Top Result**: "Project 2025: An Unconstitutional Overreach" (59.2% similarity)
|
| 96 |
+
**Why correct**: Both discuss government exceeding its authority β zero shared words between query and result title.
|
| 97 |
+
|
| 98 |
+
### Example 2
|
| 99 |
+
**Query**: "economic hardship among workers"
|
| 100 |
+
**Top Result**: "Can Worker-to-Worker Organizing Help Labor Survive The Trump" (46.4% similarity)
|
| 101 |
+
**Why correct**: Both about worker economic struggles, expressed with completely different vocabulary.
|
| 102 |
+
|
| 103 |
+
### Example 3
|
| 104 |
+
**Query**: "online manipulation campaigns"
|
| 105 |
+
**Top Result**: "The resistance, online coordination and the state of our par..." (49.8% similarity)
|
| 106 |
+
**Why correct**: Both about coordinated online activity β no keyword overlap.
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## ML/AI Component Specifications
|
| 111 |
+
|
| 112 |
+
| Component | Model/Library | Key Parameters |
|
| 113 |
+
|-----------|---------------|----------------|
|
| 114 |
+
| **Sentence Embeddings** | all-MiniLM-L6-v2 (sentence-transformers) | 384 dimensions, L2-normalized, pre-computed for all 8,799 posts |
|
| 115 |
+
| **Topic Clustering** | KMeans (scikit-learn) | k tunable 2-50, pre-computed for k=3,5,8,10,15,20,30,50, cosine distance on embedding space |
|
| 116 |
+
| **Dimensionality Reduction** | UMAP (umap-learn) | n_components=2, n_neighbors=15, min_dist=0.1, metric=cosine, random_state=42 |
|
| 117 |
+
| **Network Analysis** | PageRank + Betweenness centrality (NetworkX), Louvain community detection (python-louvain) | 3 edge types with weights 3.0/2.0/1.0, [deleted] excluded |
|
| 118 |
+
| **LLM Summaries** | Gemma 3 27B (Google AI via google-generativeai) | temperature=0.3, max_tokens=500-700, in-memory caching, used for chart summaries, search answers, cluster analysis, network analysis, embedding explanations, and community comparison |
|
| 119 |
+
| **Embedding Visualization** | Datamapplot | Interactive HTML with search, zoom, pan |
|
| 120 |
+
| **Language Detection** | langdetect | Used for non-English query detection before LLM translation |
|
| 121 |
+
| **Semantic Search** | Cosine similarity via numpy dot product on L2-normalized embeddings | Threshold 0.45 for quality, 0.30 for time-series matching |
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## Tech Stack
|
| 126 |
+
|
| 127 |
+
| Layer | Technology | Why |
|
| 128 |
+
|-------|-----------|-----|
|
| 129 |
+
| Backend | Flask (Python) | Lightweight, matches job requirements |
|
| 130 |
+
| Frontend | React.js (Vite) + Tailwind CSS | Modern, fast builds, matches job requirements |
|
| 131 |
+
| Database | SQLite | Scale-appropriate for 8.8K rows, ships as single file |
|
| 132 |
+
| Charts | Recharts | React-native, clean time-series support |
|
| 133 |
+
| Network Viz | react-force-graph-2d | WebGL-backed, handles hundreds of nodes |
|
| 134 |
+
| Deployment | Hugging Face Spaces (Docker) | Free, supports ML model loading, 16GB RAM |
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Local Setup
|
| 139 |
+
|
| 140 |
+
### Prerequisites
|
| 141 |
+
- Python 3.11+
|
| 142 |
+
- Node.js 22+
|
| 143 |
+
|
| 144 |
+
### Backend
|
| 145 |
+
```bash
|
| 146 |
+
cd backend
|
| 147 |
+
pip install -r requirements.txt
|
| 148 |
+
python app.py
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Frontend
|
| 152 |
+
```bash
|
| 153 |
+
cd frontend
|
| 154 |
+
npm install
|
| 155 |
+
npm run dev
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### Environment Variables
|
| 159 |
+
Create a `.env` file in the project root:
|
| 160 |
+
```
|
| 161 |
+
GEMINI_API_KEY=your_google_ai_api_key
|
| 162 |
+
GEMINI_MODEL=gemma-3-27b-it
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
### Data Pipeline (to regenerate pre-computed data)
|
| 166 |
+
```bash
|
| 167 |
+
cd backend
|
| 168 |
+
python pipeline/ingest.py # JSONL β SQLite
|
| 169 |
+
python pipeline/embed.py # Generate embeddings
|
| 170 |
+
python pipeline/reduce_dims.py # UMAP 2D projection
|
| 171 |
+
python pipeline/build_graph.py # Network graph
|
| 172 |
+
python pipeline/cluster.py # Topic clusters
|
| 173 |
+
python pipeline/build_datamapplot.py # Embedding visualization HTML
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## Architecture
|
| 179 |
+
|
| 180 |
+
### System Overview
|
| 181 |
+
|
| 182 |
+
```
|
| 183 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 184 |
+
β CLIENT (Browser) β
|
| 185 |
+
β β
|
| 186 |
+
β React.js SPA (Vite build) β
|
| 187 |
+
β βββ Landing β animated hero with call to action β
|
| 188 |
+
β βββ Overview β metrics, timeline, key findings β
|
| 189 |
+
β βββ Time Series β post volume, engagement, topic trends β
|
| 190 |
+
β βββ Network β force-directed graph, node removal β
|
| 191 |
+
β βββ Topics β KMeans clusters, donut chart, detail panels β
|
| 192 |
+
β βββ Compare β side-by-side comparison of two communities β
|
| 193 |
+
β βββ SearchAI β semantic search chatbot, time-series chart β
|
| 194 |
+
β βββ Embeddings β Datamapplot interactive visualization β
|
| 195 |
+
β β
|
| 196 |
+
β Libraries: Recharts, react-force-graph-2d, Axios, Tailwind CSS β
|
| 197 |
+
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
+
β HTTP (same origin)
|
| 199 |
+
βΌ
|
| 200 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 201 |
+
β FLASK SERVER (gunicorn) β
|
| 202 |
+
β β
|
| 203 |
+
β API Endpoints: β
|
| 204 |
+
β βββ /api/v1/overview/stats β dataset statistics β
|
| 205 |
+
β βββ /api/v1/compare β side-by-side subreddit compare β
|
| 206 |
+
β βββ /api/v1/embeddings/summary β AI explanation of embedding map β
|
| 207 |
+
β βββ /api/v1/timeseries/posts β post volume over time β
|
| 208 |
+
β βββ /api/v1/timeseries/engagement β engagement metrics over time β
|
| 209 |
+
β βββ /api/v1/timeseries/topics β topic trends over time β
|
| 210 |
+
β βββ /api/v1/search β semantic search + LLM answer β
|
| 211 |
+
β βββ /api/v1/search/timeseries β search results over time β
|
| 212 |
+
β βββ /api/v1/network/graph β network with centrality β
|
| 213 |
+
β βββ /api/v1/network/remove-node β node removal simulation β
|
| 214 |
+
β βββ /api/v1/clusters β topic clusters with tunable k β
|
| 215 |
+
β β
|
| 216 |
+
β In-memory at startup: β
|
| 217 |
+
β βββ embeddings.npy (8799 Γ 384) β sentence embeddings β
|
| 218 |
+
β βββ graph.json (320 nodes, 773 edges) β pre-computed network β
|
| 219 |
+
β βββ SentenceTransformer model β for query embedding β
|
| 220 |
+
β β
|
| 221 |
+
β On-disk: posts.db (SQLite) β
|
| 222 |
+
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
+
β API call (LLM only)
|
| 224 |
+
βΌ
|
| 225 |
+
βββββββββββββββββββ
|
| 226 |
+
β Google AI β
|
| 227 |
+
β Gemma 3 27B β
|
| 228 |
+
β - chart summariesβ
|
| 229 |
+
β - search answers β
|
| 230 |
+
β - translations β
|
| 231 |
+
βββββββββββββββββββ
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
### Data Pipeline (runs once during build)
|
| 235 |
+
|
| 236 |
+
```
|
| 237 |
+
data.jsonl (8,799 Reddit posts, 44MB)
|
| 238 |
+
β
|
| 239 |
+
βββ ingest.py βββββββββββ posts.db (SQLite, 16MB)
|
| 240 |
+
β 8,799 rows, indexed by subreddit/author/date
|
| 241 |
+
β
|
| 242 |
+
βββ embed.py ββββββββββββ embeddings.npy (8799 Γ 384, 13MB)
|
| 243 |
+
β all-MiniLM-L6-v2, L2-normalized
|
| 244 |
+
β
|
| 245 |
+
βββ reduce_dims.py ββββββ umap_coords.npy (8799 Γ 2)
|
| 246 |
+
β UMAP: n_neighbors=15, min_dist=0.1, cosine
|
| 247 |
+
β
|
| 248 |
+
βββ build_graph.py ββββββ graph.json (320 nodes, 773 edges)
|
| 249 |
+
β 3 edge types, PageRank, betweenness, Louvain
|
| 250 |
+
β [deleted] excluded
|
| 251 |
+
β
|
| 252 |
+
βββ cluster.py ββββββββββ cluster_assignments in SQLite
|
| 253 |
+
β KMeans for k=3,5,8,10,15,20,30,50
|
| 254 |
+
β
|
| 255 |
+
βββ build_datamapplot.py β datamapplot.html
|
| 256 |
+
Interactive embedding visualization
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
### Runtime Data Flow (per search query)
|
| 260 |
+
|
| 261 |
+
```
|
| 262 |
+
User types "immigration policy"
|
| 263 |
+
β
|
| 264 |
+
ββ 1. Validate input (not empty, not greeting)
|
| 265 |
+
ββ 2. Detect language β "en" (if non-English β translate via LLM)
|
| 266 |
+
ββ 3. Embed query with all-MiniLM-L6-v2 β 384-dim vector (~5ms)
|
| 267 |
+
ββ 4. Cosine similarity: query Γ 8,799 embeddings (<10ms)
|
| 268 |
+
ββ 5. Rank by similarity, take top 20
|
| 269 |
+
ββ 6. Fetch post details from SQLite
|
| 270 |
+
ββ 7. LLM generates conversational answer (~3-5s)
|
| 271 |
+
ββ 8. Return: answer + results + follow-up suggestions + time-series
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
Pre-computed artifacts are generated once during the pipeline phase. At runtime, the only computation is query embedding (~5ms), cosine similarity search (<10ms), and LLM API calls (~3-5s).
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## Edge Case Handling
|
| 279 |
+
|
| 280 |
+
| Scenario | Behavior |
|
| 281 |
+
|----------|----------|
|
| 282 |
+
| Empty search query | "Please type a question..." + starter suggestions |
|
| 283 |
+
| Very short query ("a") | "Too short for semantic search" + suggestion chips |
|
| 284 |
+
| Non-English (Hindi, Japanese, Spanish, etc.) | Detect language, translate via LLM, search, show translation note |
|
| 285 |
+
| Gibberish ("asdfghjkl") | "No strong matches found" + helpful suggestions |
|
| 286 |
+
| Cluster k=100+ | Clamped to 50 with warning message |
|
| 287 |
+
| Cluster k=-5, 0, 1 | Clamped to 2 with warning message |
|
| 288 |
+
| Network node removal | Shows fragmentation impact (before/after component count) |
|
| 289 |
+
| Non-existent node removal | 404 with "Author not found" message |
|
| 290 |
+
| Disconnected graph components | Count displayed in stats, no crash |
|
| 291 |
+
| Greeting ("hello", "hola") | Friendly intro + suggestion chips |
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
## A Note on r/worldpolitics
|
| 296 |
+
|
| 297 |
+
r/worldpolitics is one of the 10 subreddits in this dataset and is a known case study in unmoderated community drift β what was originally a political discussion sub became almost entirely off-topic and NSFW content over time. Every post in this subreddit is flagged `over_18 = true` in the source data.
|
| 298 |
+
|
| 299 |
+
I chose to keep r/worldpolitics in all analyses (stats, network graph, clusters, embeddings) because removing it would invalidate the comparison with the other 9 moderated political subreddits and change the network topology. The contrast between 9 moderated communities and 1 unmoderated one is itself a finding worth surfacing β it's exactly the kind of "actor or community drift" SimPPL builds tools to study.
|
| 300 |
+
|
| 301 |
+
Where individual post titles are shown in the UI (Compare, Clusters, Search), I display a small contextual note next to r/worldpolitics content rather than filtering it out. The dashboard is a research tool, not a consumer product β hiding data would be editorializing.
|
backend/routes/clusters.py
CHANGED
|
@@ -50,7 +50,8 @@ def get_clusters():
|
|
| 50 |
placeholders = ','.join(['?' for _ in all_pids])
|
| 51 |
top = conn.execute(f"""
|
| 52 |
SELECT id, title, subreddit, score, author, permalink, created_date FROM posts
|
| 53 |
-
WHERE id IN ({placeholders})
|
|
|
|
| 54 |
""", all_pids).fetchall()
|
| 55 |
clusters[cid]['top_posts'] = [
|
| 56 |
{'id': t[0], 'title': t[1], 'subreddit': t[2], 'score': t[3],
|
|
@@ -110,11 +111,12 @@ def get_clusters():
|
|
| 110 |
label = f"Cluster {i}"
|
| 111 |
|
| 112 |
cluster_post_ids = [post_ids[j] for j in range(len(labels)) if labels[j] == i]
|
| 113 |
-
pids_sample = cluster_post_ids[:
|
| 114 |
placeholders = ','.join(['?' for _ in pids_sample])
|
| 115 |
top = conn.execute(f"""
|
| 116 |
SELECT id, title, subreddit, score FROM posts
|
| 117 |
-
WHERE id IN ({placeholders})
|
|
|
|
| 118 |
""", pids_sample).fetchall()
|
| 119 |
|
| 120 |
clusters[i] = {
|
|
|
|
| 50 |
placeholders = ','.join(['?' for _ in all_pids])
|
| 51 |
top = conn.execute(f"""
|
| 52 |
SELECT id, title, subreddit, score, author, permalink, created_date FROM posts
|
| 53 |
+
WHERE id IN ({placeholders})
|
| 54 |
+
ORDER BY score DESC LIMIT 10
|
| 55 |
""", all_pids).fetchall()
|
| 56 |
clusters[cid]['top_posts'] = [
|
| 57 |
{'id': t[0], 'title': t[1], 'subreddit': t[2], 'score': t[3],
|
|
|
|
| 111 |
label = f"Cluster {i}"
|
| 112 |
|
| 113 |
cluster_post_ids = [post_ids[j] for j in range(len(labels)) if labels[j] == i]
|
| 114 |
+
pids_sample = cluster_post_ids[:10]
|
| 115 |
placeholders = ','.join(['?' for _ in pids_sample])
|
| 116 |
top = conn.execute(f"""
|
| 117 |
SELECT id, title, subreddit, score FROM posts
|
| 118 |
+
WHERE id IN ({placeholders})
|
| 119 |
+
ORDER BY score DESC LIMIT 5
|
| 120 |
""", pids_sample).fetchall()
|
| 121 |
|
| 122 |
clusters[i] = {
|
backend/routes/overview.py
CHANGED
|
@@ -3,11 +3,99 @@ Overview / stats endpoints.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import sqlite3
|
| 6 |
-
from flask import Blueprint, jsonify, current_app
|
| 7 |
|
| 8 |
overview_bp = Blueprint('overview', __name__)
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
@overview_bp.route('/overview/stats')
|
| 12 |
def get_stats():
|
| 13 |
conn = sqlite3.connect(current_app.config['db_path'])
|
|
@@ -63,3 +151,59 @@ def get_stats():
|
|
| 63 |
stats['executive_summary'] = generate_overview_summary(stats)
|
| 64 |
|
| 65 |
return jsonify(stats)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import sqlite3
|
| 6 |
+
from flask import Blueprint, jsonify, current_app, request
|
| 7 |
|
| 8 |
overview_bp = Blueprint('overview', __name__)
|
| 9 |
|
| 10 |
|
| 11 |
+
VALID_SUBREDDITS = {
|
| 12 |
+
'Anarchism', 'socialism', 'democrats', 'Liberal', 'politics',
|
| 13 |
+
'PoliticalDiscussion', 'neoliberal', 'worldpolitics', 'Conservative', 'Republican'
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_subreddit_stats(conn, subreddit):
|
| 18 |
+
"""Fetch comprehensive stats for one subreddit."""
|
| 19 |
+
# Basic counts
|
| 20 |
+
counts = conn.execute("""
|
| 21 |
+
SELECT COUNT(*) as total,
|
| 22 |
+
COUNT(DISTINCT author) as authors,
|
| 23 |
+
AVG(score) as avg_score,
|
| 24 |
+
AVG(num_comments) as avg_comments,
|
| 25 |
+
MAX(score) as max_score,
|
| 26 |
+
SUM(score) as total_score
|
| 27 |
+
FROM posts WHERE subreddit = ?
|
| 28 |
+
""", (subreddit,)).fetchone()
|
| 29 |
+
|
| 30 |
+
# Top news domains
|
| 31 |
+
top_domains = conn.execute("""
|
| 32 |
+
SELECT domain, COUNT(*) as count FROM posts
|
| 33 |
+
WHERE subreddit = ?
|
| 34 |
+
AND domain NOT LIKE 'self.%'
|
| 35 |
+
AND domain != ''
|
| 36 |
+
AND domain != 'i.redd.it'
|
| 37 |
+
AND domain != 'v.redd.it'
|
| 38 |
+
AND domain != 'reddit.com'
|
| 39 |
+
GROUP BY domain ORDER BY count DESC LIMIT 10
|
| 40 |
+
""", (subreddit,)).fetchall()
|
| 41 |
+
|
| 42 |
+
# Top authors
|
| 43 |
+
top_authors = conn.execute("""
|
| 44 |
+
SELECT author, COUNT(*) as count, AVG(score) as avg_score
|
| 45 |
+
FROM posts
|
| 46 |
+
WHERE subreddit = ? AND author != '[deleted]'
|
| 47 |
+
GROUP BY author ORDER BY count DESC LIMIT 10
|
| 48 |
+
""", (subreddit,)).fetchall()
|
| 49 |
+
|
| 50 |
+
# Top topics from k=15 cluster assignments
|
| 51 |
+
top_topics = conn.execute("""
|
| 52 |
+
SELECT c.cluster_label, COUNT(*) as count
|
| 53 |
+
FROM posts p
|
| 54 |
+
JOIN cluster_assignments c ON p.id = c.post_id
|
| 55 |
+
WHERE p.subreddit = ? AND c.k = 15
|
| 56 |
+
GROUP BY c.cluster_label
|
| 57 |
+
ORDER BY count DESC
|
| 58 |
+
LIMIT 5
|
| 59 |
+
""", (subreddit,)).fetchall()
|
| 60 |
+
|
| 61 |
+
# Top posts (highest scoring)
|
| 62 |
+
top_posts = conn.execute("""
|
| 63 |
+
SELECT id, title, score, author, permalink, created_date
|
| 64 |
+
FROM posts
|
| 65 |
+
WHERE subreddit = ?
|
| 66 |
+
ORDER BY score DESC LIMIT 5
|
| 67 |
+
""", (subreddit,)).fetchall()
|
| 68 |
+
|
| 69 |
+
# Time series β weekly post volume
|
| 70 |
+
timeseries = conn.execute("""
|
| 71 |
+
SELECT strftime('%Y-%W', created_date) as week, COUNT(*) as count
|
| 72 |
+
FROM posts WHERE subreddit = ?
|
| 73 |
+
GROUP BY week ORDER BY week
|
| 74 |
+
""", (subreddit,)).fetchall()
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
'name': subreddit,
|
| 78 |
+
'total_posts': counts[0],
|
| 79 |
+
'unique_authors': counts[1],
|
| 80 |
+
'avg_score': round(counts[2], 1) if counts[2] else 0,
|
| 81 |
+
'avg_comments': round(counts[3], 1) if counts[3] else 0,
|
| 82 |
+
'max_score': counts[4] or 0,
|
| 83 |
+
'total_score': counts[5] or 0,
|
| 84 |
+
'top_domains': [{'domain': d[0], 'count': d[1]} for d in top_domains],
|
| 85 |
+
'top_authors': [
|
| 86 |
+
{'author': a[0], 'count': a[1], 'avg_score': round(a[2], 1) if a[2] else 0}
|
| 87 |
+
for a in top_authors
|
| 88 |
+
],
|
| 89 |
+
'top_topics': [{'label': t[0], 'count': t[1]} for t in top_topics],
|
| 90 |
+
'top_posts': [
|
| 91 |
+
{'id': p[0], 'title': p[1], 'score': p[2], 'author': p[3],
|
| 92 |
+
'permalink': p[4], 'date': p[5]}
|
| 93 |
+
for p in top_posts
|
| 94 |
+
],
|
| 95 |
+
'timeseries': [{'date': t[0], 'count': t[1]} for t in timeseries],
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
@overview_bp.route('/overview/stats')
|
| 100 |
def get_stats():
|
| 101 |
conn = sqlite3.connect(current_app.config['db_path'])
|
|
|
|
| 151 |
stats['executive_summary'] = generate_overview_summary(stats)
|
| 152 |
|
| 153 |
return jsonify(stats)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@overview_bp.route('/compare')
|
| 157 |
+
def compare_subreddits():
|
| 158 |
+
"""Compare two subreddits side by side."""
|
| 159 |
+
sub1 = request.args.get('sub1', 'Conservative')
|
| 160 |
+
sub2 = request.args.get('sub2', 'socialism')
|
| 161 |
+
|
| 162 |
+
# Validate inputs
|
| 163 |
+
if sub1 not in VALID_SUBREDDITS or sub2 not in VALID_SUBREDDITS:
|
| 164 |
+
return jsonify({
|
| 165 |
+
'error': True,
|
| 166 |
+
'message': f'Invalid subreddit. Must be one of: {", ".join(sorted(VALID_SUBREDDITS))}'
|
| 167 |
+
}), 400
|
| 168 |
+
|
| 169 |
+
if sub1 == sub2:
|
| 170 |
+
return jsonify({
|
| 171 |
+
'error': True,
|
| 172 |
+
'message': 'Please select two different subreddits to compare.'
|
| 173 |
+
}), 400
|
| 174 |
+
|
| 175 |
+
conn = sqlite3.connect(current_app.config['db_path'])
|
| 176 |
+
sub1_stats = _get_subreddit_stats(conn, sub1)
|
| 177 |
+
sub2_stats = _get_subreddit_stats(conn, sub2)
|
| 178 |
+
conn.close()
|
| 179 |
+
|
| 180 |
+
# Generate comparison summary via LLM
|
| 181 |
+
from services.llm_service import generate_comparison_summary
|
| 182 |
+
summary = generate_comparison_summary(sub1_stats, sub2_stats)
|
| 183 |
+
|
| 184 |
+
return jsonify({
|
| 185 |
+
'sub1': sub1_stats,
|
| 186 |
+
'sub2': sub2_stats,
|
| 187 |
+
'summary': summary,
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@overview_bp.route('/embeddings/summary')
|
| 192 |
+
def embeddings_summary():
|
| 193 |
+
"""Return an AI-generated explanation of the embedding visualization."""
|
| 194 |
+
conn = sqlite3.connect(current_app.config['db_path'])
|
| 195 |
+
total_posts = conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
|
| 196 |
+
subreddit_counts = conn.execute(
|
| 197 |
+
"SELECT subreddit, COUNT(*) as count FROM posts GROUP BY subreddit ORDER BY count DESC"
|
| 198 |
+
).fetchall()
|
| 199 |
+
conn.close()
|
| 200 |
+
|
| 201 |
+
stats = {
|
| 202 |
+
'total_posts': total_posts,
|
| 203 |
+
'subreddits': [{'name': s[0], 'count': s[1]} for s in subreddit_counts],
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
from services.llm_service import generate_embeddings_summary
|
| 207 |
+
summary = generate_embeddings_summary(stats)
|
| 208 |
+
|
| 209 |
+
return jsonify({'summary': summary})
|
backend/routes/search.py
CHANGED
|
@@ -39,7 +39,7 @@ def search():
|
|
| 39 |
query_lower = query.lower().strip('?!. ')
|
| 40 |
if query_lower in greetings:
|
| 41 |
return jsonify({
|
| 42 |
-
'answer': 'Hey! I can help you explore a dataset of 8,799 Reddit posts from 10
|
| 43 |
'results': [],
|
| 44 |
'follow_up_queries': [
|
| 45 |
'What topics dominated after the inauguration?',
|
|
|
|
| 39 |
query_lower = query.lower().strip('?!. ')
|
| 40 |
if query_lower in greetings:
|
| 41 |
return jsonify({
|
| 42 |
+
'answer': 'Hey! I can help you explore a dataset of 8,799 Reddit posts from 10 subreddits (July 2024 - Feb 2025). Ask me anything about how these communities discussed events, what news they shared, or how they overlap.',
|
| 43 |
'results': [],
|
| 44 |
'follow_up_queries': [
|
| 45 |
'What topics dominated after the inauguration?',
|
backend/services/llm_service.py
CHANGED
|
@@ -67,12 +67,16 @@ def generate_timeseries_summary(series_data, metric, granularity, subreddits=Non
|
|
| 67 |
# Aggregate totals per period
|
| 68 |
period_totals = {}
|
| 69 |
sub_totals = {}
|
|
|
|
| 70 |
for item in series_data:
|
| 71 |
date = item.get('date', '')
|
| 72 |
count = item.get('count', item.get('avg', 0))
|
| 73 |
sub = item.get('subreddit', '')
|
| 74 |
period_totals[date] = period_totals.get(date, 0) + count
|
| 75 |
sub_totals[sub] = sub_totals.get(sub, 0) + count
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
periods = sorted(period_totals.keys())
|
| 78 |
if not periods:
|
|
@@ -84,46 +88,81 @@ def generate_timeseries_summary(series_data, metric, granularity, subreddits=Non
|
|
| 84 |
lowest_val = period_totals[lowest_period]
|
| 85 |
top_sub = max(sub_totals, key=sub_totals.get) if sub_totals else "N/A"
|
| 86 |
top_3_subs = sorted(sub_totals.items(), key=lambda x: -x[1])[:3]
|
|
|
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
What the chart shows: {metric} per {granularity}, {sub_filter}
|
| 93 |
Period covered: {periods[0]} to {periods[-1]}
|
| 94 |
Number of {granularity}s shown: {len(periods)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
Lowest point: {lowest_period} with {lowest_val:.0f}
|
| 96 |
Highest point: {peak_period} with {peak_val:.0f}
|
| 97 |
-
Top 3 subreddits by volume: {', '.join([f'r/{s} ({v:.0f})' for s, v in top_3_subs])}
|
| 98 |
Starting value: {period_totals.get(periods[0], 0):.0f}
|
| 99 |
Ending value: {period_totals.get(periods[-1], 0):.0f}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
if result:
|
| 109 |
return result
|
| 110 |
|
| 111 |
-
# Fallback: rule-based summary
|
| 112 |
-
|
| 113 |
-
direction = "increased" if change_pct > 0 else "decreased"
|
| 114 |
return (
|
| 115 |
-
f"Activity {direction}
|
| 116 |
-
f"
|
| 117 |
-
f"The peak occurred at {peak_period} with {peak_val:.0f}
|
| 118 |
-
f"r/{
|
|
|
|
|
|
|
|
|
|
| 119 |
)
|
| 120 |
|
| 121 |
|
| 122 |
def generate_search_response(query, results, history=None):
|
| 123 |
"""Generate a conversational response for search results."""
|
| 124 |
if not results:
|
| 125 |
-
prompt = f"""The user searched for "{query}" in a dataset of Reddit
|
| 126 |
-
Write a brief, helpful response (2 sentences max) acknowledging this and suggesting what they could try instead. Be specific to political
|
| 127 |
|
| 128 |
result = _call_llm(prompt, max_tokens=100)
|
| 129 |
return result or f'No strong matches found for "{query}". Try searching for specific political topics like immigration, tariffs, or executive orders.'
|
|
@@ -138,17 +177,24 @@ Write a brief, helpful response (2 sentences max) acknowledging this and suggest
|
|
| 138 |
for r in results[:10]:
|
| 139 |
sub_counts[r['subreddit']] = sub_counts.get(r['subreddit'], 0) + 1
|
| 140 |
|
| 141 |
-
prompt = f"""You are an analyst for a
|
| 142 |
|
| 143 |
-
Here are the top 10 most relevant Reddit posts from a dataset of 8,799 posts across 10
|
| 144 |
|
| 145 |
{results_context}
|
| 146 |
|
| 147 |
Subreddit distribution in results: {sub_counts}
|
| 148 |
|
| 149 |
-
Write a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
result = _call_llm(prompt, max_tokens=
|
| 152 |
if result:
|
| 153 |
return result
|
| 154 |
|
|
@@ -175,7 +221,7 @@ def generate_follow_up_queries(query, results):
|
|
| 175 |
for r in results[:5]
|
| 176 |
])
|
| 177 |
|
| 178 |
-
prompt = f"""The user searched for "{query}" in a Reddit
|
| 179 |
|
| 180 |
{results_context}
|
| 181 |
|
|
@@ -205,22 +251,31 @@ Return ONLY the 3 questions, one per line, no numbering or bullets."""
|
|
| 205 |
|
| 206 |
def generate_overview_summary(stats):
|
| 207 |
"""Generate an executive summary for the overview page."""
|
| 208 |
-
prompt = f"""Write a plain-text summary (NO markdown, NO headers, NO #, NO bullet points
|
| 209 |
|
| 210 |
Dataset: {stats['total_posts']} Reddit posts from {stats['total_authors']} authors
|
| 211 |
Subreddits: {', '.join([f"r/{s['name']} ({s['count']})" for s in stats['subreddits']])}
|
| 212 |
Date range: {stats['date_range']['start']} to {stats['date_range']['end']}
|
| 213 |
-
Top news sources: {', '.join([f"{d['domain']} ({d['count']} shares)" for d in stats['top_domains'][:
|
| 214 |
-
Network: {stats['network_stats']['num_nodes']} connected authors, {stats['network_stats']['num_components']} separate components
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
2. One specific insight: which communities share which news sources (give exact names and numbers)
|
| 219 |
-
3. One specific insight: what the fragmented network (72 components) tells us about cross-community dialogue
|
| 220 |
|
| 221 |
-
Do NOT use any markdown formatting. Do NOT start with "Executive Summary" or any title.
|
| 222 |
|
| 223 |
-
result = _call_llm(prompt, max_tokens=
|
| 224 |
if result:
|
| 225 |
# Strip any markdown the LLM might still add
|
| 226 |
cleaned = result.strip()
|
|
@@ -237,7 +292,7 @@ Do NOT use any markdown formatting. Do NOT start with "Executive Summary" or any
|
|
| 237 |
|
| 238 |
return (
|
| 239 |
f"This dataset captures {stats['total_posts']} posts from {stats['total_authors']} authors "
|
| 240 |
-
f"across 10
|
| 241 |
f"The period covers the 2024 US presidential election through the first weeks of the new administration. "
|
| 242 |
f"Top shared news sources include {', '.join([d['domain'] for d in stats['top_domains'][:3]])}."
|
| 243 |
)
|
|
@@ -250,14 +305,21 @@ def generate_cluster_summary(clusters, k):
|
|
| 250 |
for c in sorted(clusters, key=lambda x: -x['size'])[:10]
|
| 251 |
])
|
| 252 |
|
| 253 |
-
prompt = f"""Write a plain-text
|
| 254 |
|
| 255 |
-
{k} clusters were created using KMeans. Here are the largest
|
| 256 |
{cluster_desc}
|
| 257 |
|
| 258 |
-
Write
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
-
|
|
|
|
|
|
|
| 261 |
if result:
|
| 262 |
import re
|
| 263 |
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
|
@@ -267,14 +329,125 @@ Write 2-3 sentences explaining: what are the dominant topics, which topics overl
|
|
| 267 |
|
| 268 |
def generate_network_summary(stats):
|
| 269 |
"""Generate a summary of the network analysis."""
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
Network stats: {stats['num_nodes']} connected authors, {stats['num_edges']} edges, {stats['num_components']} disconnected components, {stats.get('num_communities', 'unknown')} communities detected.
|
| 273 |
-
Density: {stats.get('density', 'unknown')}
|
| 274 |
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
if result:
|
| 279 |
import re
|
| 280 |
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
|
@@ -284,7 +457,7 @@ Write 2-3 sentences explaining: what does the high number of components mean (fr
|
|
| 284 |
|
| 285 |
def answer_chart_question(question, data_context):
|
| 286 |
"""Answer a user's follow-up question about a specific chart's data."""
|
| 287 |
-
prompt = f"""You are analyzing a chart from a Reddit
|
| 288 |
|
| 289 |
Chart data and context:
|
| 290 |
{data_context}
|
|
|
|
| 67 |
# Aggregate totals per period
|
| 68 |
period_totals = {}
|
| 69 |
sub_totals = {}
|
| 70 |
+
sub_period_totals = {} # {sub: {period: count}}
|
| 71 |
for item in series_data:
|
| 72 |
date = item.get('date', '')
|
| 73 |
count = item.get('count', item.get('avg', 0))
|
| 74 |
sub = item.get('subreddit', '')
|
| 75 |
period_totals[date] = period_totals.get(date, 0) + count
|
| 76 |
sub_totals[sub] = sub_totals.get(sub, 0) + count
|
| 77 |
+
if sub not in sub_period_totals:
|
| 78 |
+
sub_period_totals[sub] = {}
|
| 79 |
+
sub_period_totals[sub][date] = sub_period_totals[sub].get(date, 0) + count
|
| 80 |
|
| 81 |
periods = sorted(period_totals.keys())
|
| 82 |
if not periods:
|
|
|
|
| 88 |
lowest_val = period_totals[lowest_period]
|
| 89 |
top_sub = max(sub_totals, key=sub_totals.get) if sub_totals else "N/A"
|
| 90 |
top_3_subs = sorted(sub_totals.items(), key=lambda x: -x[1])[:3]
|
| 91 |
+
bottom_3_subs = sorted(sub_totals.items(), key=lambda x: x[1])[:3]
|
| 92 |
|
| 93 |
+
# Compute first half vs second half average
|
| 94 |
+
mid = len(periods) // 2
|
| 95 |
+
first_half_avg = sum(period_totals[p] for p in periods[:mid]) / max(mid, 1)
|
| 96 |
+
second_half_avg = sum(period_totals[p] for p in periods[mid:]) / max(len(periods) - mid, 1)
|
| 97 |
+
pct_change = ((second_half_avg - first_half_avg) / max(first_half_avg, 1)) * 100
|
| 98 |
|
| 99 |
+
# Find which subreddit had the biggest spike near the peak
|
| 100 |
+
peak_contributors = sorted(
|
| 101 |
+
[(s, sub_period_totals[s].get(peak_period, 0)) for s in sub_period_totals],
|
| 102 |
+
key=lambda x: -x[1]
|
| 103 |
+
)[:3]
|
| 104 |
|
| 105 |
+
total_volume = sum(period_totals.values())
|
| 106 |
+
avg_per_period = total_volume / max(len(periods), 1)
|
| 107 |
+
|
| 108 |
+
prompt = f"""Write a detailed 5-6 sentence plain-language summary explaining this time-series chart to someone who cannot read charts. The reader should understand the trend, the key shifts, who drove the activity, and what the data reveals β purely from your summary.
|
| 109 |
+
|
| 110 |
+
IMPORTANT: The dataset covers Reddit posts from July 2024 to February 2025 ONLY. Do NOT mention any dates outside this range. Trump's inauguration was on January 20, 2025.
|
| 111 |
+
|
| 112 |
+
CHART CONTEXT
|
| 113 |
What the chart shows: {metric} per {granularity}, {sub_filter}
|
| 114 |
Period covered: {periods[0]} to {periods[-1]}
|
| 115 |
Number of {granularity}s shown: {len(periods)}
|
| 116 |
+
Total volume across the entire period: {total_volume:.0f}
|
| 117 |
+
Average per {granularity}: {avg_per_period:.1f}
|
| 118 |
+
|
| 119 |
+
KEY POINTS
|
| 120 |
Lowest point: {lowest_period} with {lowest_val:.0f}
|
| 121 |
Highest point: {peak_period} with {peak_val:.0f}
|
|
|
|
| 122 |
Starting value: {period_totals.get(periods[0], 0):.0f}
|
| 123 |
Ending value: {period_totals.get(periods[-1], 0):.0f}
|
| 124 |
+
First half average: {first_half_avg:.1f}
|
| 125 |
+
Second half average: {second_half_avg:.1f}
|
| 126 |
+
Change between halves: {pct_change:+.0f}%
|
| 127 |
+
|
| 128 |
+
SUBREDDIT BREAKDOWN
|
| 129 |
+
Top 3 subreddits by total volume: {', '.join([f'r/{s} ({v:.0f})' for s, v in top_3_subs])}
|
| 130 |
+
Bottom 3 subreddits: {', '.join([f'r/{s} ({v:.0f})' for s, v in bottom_3_subs])}
|
| 131 |
+
Top 3 subreddits driving the peak at {peak_period}: {', '.join([f'r/{s} ({v:.0f})' for s, v in peak_contributors])}
|
| 132 |
+
|
| 133 |
+
INSTRUCTIONS
|
| 134 |
+
- Write 5 to 6 sentences, in plain English, no markdown, no bullet points.
|
| 135 |
+
- Sentence 1: Describe the overall shape of the trend (was it flat, growing, falling, spiky?) and the magnitude of change between halves.
|
| 136 |
+
- Sentence 2: Pinpoint the peak moment and explain what subreddits drove it.
|
| 137 |
+
- Sentence 3: Compare the most active and least active subreddits β what does this say about which communities dominated the conversation?
|
| 138 |
+
- Sentence 4: Mention any clear inflection point (e.g. activity surge after January 20, 2025 inauguration).
|
| 139 |
+
- Sentence 5-6: End with a takeaway β what does this trend reveal about how these communities discussed events during this period?
|
| 140 |
+
- Use ONLY the numbers provided above. Do not invent any numbers, dates, or subreddit names.
|
| 141 |
+
- Do NOT start with "The chart shows" or "This data shows". State findings directly.
|
| 142 |
+
- Be analytical, like a journalist writing for a non-technical audience."""
|
| 143 |
+
|
| 144 |
+
result = _call_llm(prompt, max_tokens=500)
|
| 145 |
if result:
|
| 146 |
return result
|
| 147 |
|
| 148 |
+
# Fallback: richer rule-based summary
|
| 149 |
+
direction = "rose sharply" if pct_change > 30 else "declined" if pct_change < -30 else "stayed relatively stable"
|
|
|
|
| 150 |
return (
|
| 151 |
+
f"Activity {direction} over the {len(periods)} {granularity}s shown, with the average shifting from "
|
| 152 |
+
f"{first_half_avg:.0f} in the first half to {second_half_avg:.0f} in the second half ({pct_change:+.0f}%). "
|
| 153 |
+
f"The peak occurred at {peak_period} with {peak_val:.0f} β driven primarily by "
|
| 154 |
+
f"{', '.join([f'r/{s}' for s, _ in peak_contributors[:2]])}. "
|
| 155 |
+
f"Across the entire period, r/{top_sub} dominated with {sub_totals.get(top_sub, 0):.0f} total, "
|
| 156 |
+
f"while r/{bottom_3_subs[0][0]} contributed only {bottom_3_subs[0][1]:.0f}. "
|
| 157 |
+
f"This concentration suggests conversation during this period was unevenly distributed across communities."
|
| 158 |
)
|
| 159 |
|
| 160 |
|
| 161 |
def generate_search_response(query, results, history=None):
|
| 162 |
"""Generate a conversational response for search results."""
|
| 163 |
if not results:
|
| 164 |
+
prompt = f"""The user searched for "{query}" in a dataset of Reddit posts from 10 politically associated subreddits, but no strong matches were found.
|
| 165 |
+
Write a brief, helpful response (2 sentences max) acknowledging this and suggesting what they could try instead. Be specific to political topics relevant to the 2024 US election and 2025 transition."""
|
| 166 |
|
| 167 |
result = _call_llm(prompt, max_tokens=100)
|
| 168 |
return result or f'No strong matches found for "{query}". Try searching for specific political topics like immigration, tariffs, or executive orders.'
|
|
|
|
| 177 |
for r in results[:10]:
|
| 178 |
sub_counts[r['subreddit']] = sub_counts.get(r['subreddit'], 0) + 1
|
| 179 |
|
| 180 |
+
prompt = f"""You are an analyst for a research dashboard tracing narratives across Reddit communities. The user searched for: "{query}"
|
| 181 |
|
| 182 |
+
Here are the top 10 most relevant Reddit posts from a dataset of 8,799 posts across 10 subreddits (July 2024 - Feb 2025) collected for their political associations:
|
| 183 |
|
| 184 |
{results_context}
|
| 185 |
|
| 186 |
Subreddit distribution in results: {sub_counts}
|
| 187 |
|
| 188 |
+
Write a detailed 4-5 sentence analytical response answering the user's query based on this data. Structure it like this:
|
| 189 |
+
- Open with a direct answer to "{query}" based on what the results show
|
| 190 |
+
- Describe which communities are most engaged with this topic and how the distribution skews
|
| 191 |
+
- Highlight one or two specific posts that best illustrate the finding (cite post titles)
|
| 192 |
+
- Note any contrast or pattern across communities (e.g., "left-leaning subs frame it differently from right-leaning")
|
| 193 |
+
- End with a takeaway or what's notable about this finding
|
| 194 |
+
|
| 195 |
+
Use ONLY the data above. Be specific with subreddit names, post titles, and counts. Do NOT use markdown headers or bullet points β write flowing prose."""
|
| 196 |
|
| 197 |
+
result = _call_llm(prompt, max_tokens=450)
|
| 198 |
if result:
|
| 199 |
return result
|
| 200 |
|
|
|
|
| 221 |
for r in results[:5]
|
| 222 |
])
|
| 223 |
|
| 224 |
+
prompt = f"""The user searched for "{query}" in a dataset of Reddit posts from 10 politically associated subreddits and got these top results:
|
| 225 |
|
| 226 |
{results_context}
|
| 227 |
|
|
|
|
| 251 |
|
| 252 |
def generate_overview_summary(stats):
|
| 253 |
"""Generate an executive summary for the overview page."""
|
| 254 |
+
prompt = f"""Write a plain-text executive summary (NO markdown, NO headers, NO #, NO bullet points) for an investigative reporting dashboard tracing narratives across 10 Reddit communities collected for their political associations.
|
| 255 |
|
| 256 |
Dataset: {stats['total_posts']} Reddit posts from {stats['total_authors']} authors
|
| 257 |
Subreddits: {', '.join([f"r/{s['name']} ({s['count']})" for s in stats['subreddits']])}
|
| 258 |
Date range: {stats['date_range']['start']} to {stats['date_range']['end']}
|
| 259 |
+
Top news sources: {', '.join([f"{d['domain']} ({d['count']} shares)" for d in stats['top_domains'][:8]])}
|
| 260 |
+
Network: {stats['network_stats']['num_nodes']} connected authors, {stats['network_stats']['num_edges']} edges, {stats['network_stats']['num_components']} separate components
|
| 261 |
+
|
| 262 |
+
Write exactly 4 substantial paragraphs (3-4 sentences each), plain text only:
|
| 263 |
+
|
| 264 |
+
Paragraph 1 β Setting the stage:
|
| 265 |
+
Describe what this dataset captures and why the time period (July 2024 to February 2025) matters historically. Reference the 2024 US presidential election and the January 20, 2025 inauguration of Trump's second term. Mention that the 10 subreddits were collected for their political associations and span the full political spectrum.
|
| 266 |
+
|
| 267 |
+
Paragraph 2 β Volume and concentration:
|
| 268 |
+
Explain that 83% of all activity (7,286 of 8,799 posts) is concentrated in January-February 2025, after the inauguration. Average daily posting jumped from ~13 posts/day to ~217 posts/day after January 20 β a 1,500% surge. Explain why this matters for tracing how narratives spread.
|
| 269 |
+
|
| 270 |
+
Paragraph 3 β Media ecosystem fragmentation:
|
| 271 |
+
Use the top news sources data to show how different subreddits share fundamentally different sources. For example, r/Conservative shares breitbart.com and foxnews.com, while r/politics shares nytimes.com and theguardian.com. Reference at least 4 specific domains by name with their share counts. This is a sign of isolated information ecosystems.
|
| 272 |
|
| 273 |
+
Paragraph 4 β Network structure:
|
| 274 |
+
Explain what {stats['network_stats']['num_components']} disconnected components in a {stats['network_stats']['num_nodes']}-node network reveals about cross-community dialogue. Most communities operate in isolation, but ~87 cross-community authors act as bridges. Comment on what this fragmentation means for the spread of narratives.
|
|
|
|
|
|
|
| 275 |
|
| 276 |
+
Do NOT use any markdown formatting. Do NOT start with "Executive Summary" or any title. Write each paragraph as a standalone block separated by a blank line."""
|
| 277 |
|
| 278 |
+
result = _call_llm(prompt, max_tokens=700)
|
| 279 |
if result:
|
| 280 |
# Strip any markdown the LLM might still add
|
| 281 |
cleaned = result.strip()
|
|
|
|
| 292 |
|
| 293 |
return (
|
| 294 |
f"This dataset captures {stats['total_posts']} posts from {stats['total_authors']} authors "
|
| 295 |
+
f"across 10 subreddits collected for their political associations, spanning {stats['date_range']['start']} to {stats['date_range']['end']}. "
|
| 296 |
f"The period covers the 2024 US presidential election through the first weeks of the new administration. "
|
| 297 |
f"Top shared news sources include {', '.join([d['domain'] for d in stats['top_domains'][:3]])}."
|
| 298 |
)
|
|
|
|
| 305 |
for c in sorted(clusters, key=lambda x: -x['size'])[:10]
|
| 306 |
])
|
| 307 |
|
| 308 |
+
prompt = f"""Write a detailed plain-text analysis (NO markdown, NO headers, NO #) of these topic clusters from a Reddit dataset (8,799 posts, 10 subreddits collected for their political associations, July 2024 to February 2025, covering the 2024 US election and 2025 presidential transition).
|
| 309 |
|
| 310 |
+
{k} clusters were created using KMeans on 384-dimensional sentence embeddings. Here are the largest clusters:
|
| 311 |
{cluster_desc}
|
| 312 |
|
| 313 |
+
Write 5 to 6 sentences covering:
|
| 314 |
+
1. What are the dominant themes that emerge across the largest clusters? Name at least 3 specific clusters by their keywords.
|
| 315 |
+
2. Which clusters reflect election-period concerns (campaigns, voting, candidates) versus post-inauguration governance (executive orders, immigration, federal workforce)?
|
| 316 |
+
3. Are there any surprising or unexpected clusters β small ones, or topics that wouldn't normally appear in politically associated subreddits?
|
| 317 |
+
4. What does the distribution of cluster sizes tell us β are a few topics dominating the conversation, or is the conversation spread evenly across many topics?
|
| 318 |
+
5. End with a takeaway about what these communities were discussing during this seven-month window.
|
| 319 |
|
| 320 |
+
Use specific cluster keywords, exact post counts, and percentages where relevant. Do NOT use markdown, bullet points, or headers β write flowing analytical prose."""
|
| 321 |
+
|
| 322 |
+
result = _call_llm(prompt, max_tokens=500)
|
| 323 |
if result:
|
| 324 |
import re
|
| 325 |
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
|
|
|
| 329 |
|
| 330 |
def generate_network_summary(stats):
|
| 331 |
"""Generate a summary of the network analysis."""
|
| 332 |
+
num_nodes = stats.get('num_nodes', 0)
|
| 333 |
+
num_edges = stats.get('num_edges', 0)
|
| 334 |
+
num_components = stats.get('num_components', 0)
|
| 335 |
+
num_communities = stats.get('num_communities', 'unknown')
|
| 336 |
+
density = stats.get('density', 'unknown')
|
| 337 |
+
largest = stats.get('largest_component_size', 'unknown')
|
| 338 |
+
|
| 339 |
+
prompt = f"""Write a detailed plain-text analysis (NO markdown, NO headers, NO #) of this author interaction network built from a Reddit dataset (8,799 posts, 10 subreddits collected for their political associations, July 2024 to February 2025).
|
| 340 |
+
|
| 341 |
+
The network is built from three signal types: crosspost links (weight 3.0), shared URL co-sharing (weight 2.0), and co-subreddit activity (weight 1.0). The [deleted] meta-author is excluded to prevent false super-connections.
|
| 342 |
+
|
| 343 |
+
NETWORK STATS
|
| 344 |
+
Total connected authors (nodes): {num_nodes}
|
| 345 |
+
Total interaction edges: {num_edges}
|
| 346 |
+
Disconnected components: {num_components}
|
| 347 |
+
Communities detected (Louvain algorithm): {num_communities}
|
| 348 |
+
Network density: {density}
|
| 349 |
+
Largest connected component size: {largest}
|
| 350 |
+
|
| 351 |
+
Write 5 to 6 sentences covering:
|
| 352 |
+
1. What does {num_components} disconnected components in a {num_nodes}-node network reveal about how fragmented or unified author interaction is across these Reddit communities?
|
| 353 |
+
2. What does the density of {density} tell us about how interconnected authors are in absolute terms? (Density of 1.0 would mean every author interacts with every other; density near 0 means very sparse interaction.)
|
| 354 |
+
3. What do the {num_communities} Louvain communities suggest β are these likely subreddit-aligned communities or do they cross subreddit boundaries?
|
| 355 |
+
4. The largest connected component contains {largest} authors. What does the gap between this and total nodes ({num_nodes}) say about the structure of cross-community interaction?
|
| 356 |
+
5. End with a takeaway: what does this network structure imply about the spread of narratives between politically diverse Reddit communities?
|
| 357 |
+
|
| 358 |
+
Use specific numbers throughout. Do NOT use markdown, bullet points, or headers β write flowing analytical prose."""
|
| 359 |
+
|
| 360 |
+
result = _call_llm(prompt, max_tokens=500)
|
| 361 |
+
if result:
|
| 362 |
+
import re
|
| 363 |
+
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
| 364 |
+
return cleaned.replace('**', '')
|
| 365 |
+
return None
|
| 366 |
|
|
|
|
|
|
|
| 367 |
|
| 368 |
+
def generate_comparison_summary(sub1, sub2):
|
| 369 |
+
"""Generate an analytical comparison between two subreddits."""
|
| 370 |
+
sub1_domains = ', '.join([f"{d['domain']} ({d['count']})" for d in sub1['top_domains'][:5]])
|
| 371 |
+
sub2_domains = ', '.join([f"{d['domain']} ({d['count']})" for d in sub2['top_domains'][:5]])
|
| 372 |
+
sub1_topics = '; '.join([t['label'] for t in sub1['top_topics'][:3]])
|
| 373 |
+
sub2_topics = '; '.join([t['label'] for t in sub2['top_topics'][:3]])
|
| 374 |
+
sub1_top_author = sub1['top_authors'][0] if sub1['top_authors'] else None
|
| 375 |
+
sub2_top_author = sub2['top_authors'][0] if sub2['top_authors'] else None
|
| 376 |
|
| 377 |
+
prompt = f"""Write a detailed plain-text analytical comparison (NO markdown, NO headers, NO #) of two Reddit subreddits from a dataset of 8,799 posts (10 subreddits collected for their political associations, July 2024 to February 2025, covering the 2024 US election and 2025 inauguration).
|
| 378 |
+
|
| 379 |
+
SUBREDDIT 1: r/{sub1['name']}
|
| 380 |
+
- Total posts: {sub1['total_posts']}
|
| 381 |
+
- Unique authors: {sub1['unique_authors']}
|
| 382 |
+
- Average upvotes per post: {sub1['avg_score']}
|
| 383 |
+
- Average comments per post: {sub1['avg_comments']}
|
| 384 |
+
- Top news sources shared: {sub1_domains}
|
| 385 |
+
- Top discussion topics: {sub1_topics}
|
| 386 |
+
- Most active author: {f"u/{sub1_top_author['author']} ({sub1_top_author['count']} posts)" if sub1_top_author else 'N/A'}
|
| 387 |
+
|
| 388 |
+
SUBREDDIT 2: r/{sub2['name']}
|
| 389 |
+
- Total posts: {sub2['total_posts']}
|
| 390 |
+
- Unique authors: {sub2['unique_authors']}
|
| 391 |
+
- Average upvotes per post: {sub2['avg_score']}
|
| 392 |
+
- Average comments per post: {sub2['avg_comments']}
|
| 393 |
+
- Top news sources shared: {sub2_domains}
|
| 394 |
+
- Top discussion topics: {sub2_topics}
|
| 395 |
+
- Most active author: {f"u/{sub2_top_author['author']} ({sub2_top_author['count']} posts)" if sub2_top_author else 'N/A'}
|
| 396 |
+
|
| 397 |
+
Write 4 paragraphs (3-4 sentences each) covering:
|
| 398 |
+
|
| 399 |
+
Paragraph 1 β Engagement comparison:
|
| 400 |
+
Compare total post counts, unique authors, average upvotes, and average comments. Which community is more active? Which gets more engagement per post? What does the ratio of authors to posts tell us about whether discussion is concentrated in a few hands or distributed widely?
|
| 401 |
+
|
| 402 |
+
Paragraph 2 β Information ecosystem:
|
| 403 |
+
Compare the news sources each community shares. Are they reading the same outlets, or completely different ones? Cite at least 3 specific domains by name. What does this tell us about the information ecosystems each community is plugged into?
|
| 404 |
+
|
| 405 |
+
Paragraph 3 β Topical focus:
|
| 406 |
+
Compare the dominant topics in each community. Are they discussing the same events from different angles, or are they focused on entirely different concerns? Reference specific topic keywords from the data.
|
| 407 |
+
|
| 408 |
+
Paragraph 4 β The takeaway:
|
| 409 |
+
What's the most striking difference between these two communities? Is there evidence of narrative divergence, echo chambers, or shared concerns? End with a concrete observation that a journalist could use as the seed for a story.
|
| 410 |
+
|
| 411 |
+
Use ONLY the data above. Be specific with numbers and names. Do NOT use markdown formatting β write flowing analytical prose."""
|
| 412 |
+
|
| 413 |
+
result = _call_llm(prompt, max_tokens=700)
|
| 414 |
+
if result:
|
| 415 |
+
import re
|
| 416 |
+
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
| 417 |
+
return cleaned.replace('**', '')
|
| 418 |
+
return None
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def generate_embeddings_summary(stats):
|
| 422 |
+
"""Generate a plain-language summary explaining what the embedding visualization shows."""
|
| 423 |
+
subreddit_list = ', '.join([f"r/{s['name']} ({s['count']})" for s in stats.get('subreddits', [])[:10]])
|
| 424 |
+
|
| 425 |
+
prompt = f"""Write a plain-text 4-paragraph explanation (NO markdown, NO headers, NO #) helping a non-technical reader understand what they are looking at in an interactive embedding visualization.
|
| 426 |
+
|
| 427 |
+
CONTEXT
|
| 428 |
+
The visualization shows all 8,799 Reddit posts as dots on a 2D map. Posts that are semantically similar (discuss similar topics in similar ways) are placed near each other. Posts that are different are far apart. The map was created using all-MiniLM-L6-v2 sentence embeddings (384 dimensions per post) reduced to 2D using UMAP. Each post is colored by which subreddit it came from.
|
| 429 |
+
|
| 430 |
+
DATASET
|
| 431 |
+
{stats.get('total_posts', 8799)} posts across these 10 subreddits collected for their political associations: {subreddit_list}
|
| 432 |
+
Time period: July 2024 to February 2025 (covering the 2024 US election and 2025 Trump inauguration)
|
| 433 |
+
|
| 434 |
+
WRITE 4 PARAGRAPHS
|
| 435 |
+
|
| 436 |
+
Paragraph 1 β What you're looking at:
|
| 437 |
+
Explain that this is a "map of meaning" β each dot is a post, and dots near each other talk about similar things. Don't use jargon like "embedding space" or "vector dimensions." Use the metaphor of a city map where similar buildings (posts) cluster into neighborhoods (topics).
|
| 438 |
+
|
| 439 |
+
Paragraph 2 β How to read it:
|
| 440 |
+
Explain that distinct clumps of dots are topic clusters that emerged automatically β no one labeled them, the AI just grouped posts that talked about similar things. Mention that the colors show which subreddit each post is from, so you can see whether different communities cluster separately or mix together. Tell the reader to look for: tight clumps (focused topics), sparse areas (unique posts), and surprising overlaps (posts from opposing political subreddits ending up near each other).
|
| 441 |
+
|
| 442 |
+
Paragraph 3 β What this reveals about how the communities talk:
|
| 443 |
+
Discuss what an embedding map of these Reddit communities can reveal. For example: posts about Trump's executive orders form one neighborhood, posts about anarchist theory form another, posts about election results form yet another. Communities that share vocabulary will overlap in space, while ideologically distant ones stay apart. The biggest insight from this kind of visualization is finding "bridges" β posts where different political camps unexpectedly land near each other.
|
| 444 |
+
|
| 445 |
+
Paragraph 4 β How to use it:
|
| 446 |
+
Tell the reader to use the search bar inside the map to find specific topics (e.g. searching "immigration" highlights all immigration-related posts). Encourage them to zoom into a clump to read individual post titles and see what defines that neighborhood.
|
| 447 |
+
|
| 448 |
+
Use plain conversational English a curious newspaper reader would understand. Do NOT use markdown."""
|
| 449 |
+
|
| 450 |
+
result = _call_llm(prompt, max_tokens=700)
|
| 451 |
if result:
|
| 452 |
import re
|
| 453 |
cleaned = re.sub(r'^#{1,4}\s+.*$', '', result, flags=re.MULTILINE).strip()
|
|
|
|
| 457 |
|
| 458 |
def answer_chart_question(question, data_context):
|
| 459 |
"""Answer a user's follow-up question about a specific chart's data."""
|
| 460 |
+
prompt = f"""You are analyzing a chart from a Reddit research dashboard (8,799 posts from 10 subreddits collected for their political associations, Jul 2024 - Feb 2025).
|
| 461 |
|
| 462 |
Chart data and context:
|
| 463 |
{data_context}
|
frontend/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>TheScope β
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>TheScope β Tracing Narratives Across Reddit Communities</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
frontend/src/App.jsx
CHANGED
|
@@ -7,6 +7,7 @@ import Network from './pages/Network'
|
|
| 7 |
import Clusters from './pages/Clusters'
|
| 8 |
import Search from './pages/Search'
|
| 9 |
import Embeddings from './pages/Embeddings'
|
|
|
|
| 10 |
|
| 11 |
function DashboardRoutes() {
|
| 12 |
return (
|
|
@@ -16,6 +17,7 @@ function DashboardRoutes() {
|
|
| 16 |
<Route path="/timeseries" element={<TimeSeries />} />
|
| 17 |
<Route path="/network" element={<Network />} />
|
| 18 |
<Route path="/clusters" element={<Clusters />} />
|
|
|
|
| 19 |
<Route path="/search" element={<Search />} />
|
| 20 |
<Route path="/embeddings" element={<Embeddings />} />
|
| 21 |
</Routes>
|
|
|
|
| 7 |
import Clusters from './pages/Clusters'
|
| 8 |
import Search from './pages/Search'
|
| 9 |
import Embeddings from './pages/Embeddings'
|
| 10 |
+
import Compare from './pages/Compare'
|
| 11 |
|
| 12 |
function DashboardRoutes() {
|
| 13 |
return (
|
|
|
|
| 17 |
<Route path="/timeseries" element={<TimeSeries />} />
|
| 18 |
<Route path="/network" element={<Network />} />
|
| 19 |
<Route path="/clusters" element={<Clusters />} />
|
| 20 |
+
<Route path="/compare" element={<Compare />} />
|
| 21 |
<Route path="/search" element={<Search />} />
|
| 22 |
<Route path="/embeddings" element={<Embeddings />} />
|
| 23 |
</Routes>
|
frontend/src/components/common/AISummary.jsx
CHANGED
|
@@ -8,13 +8,20 @@ export default function AISummary({ text }) {
|
|
| 8 |
.replace(/^\s*[-*]\s/gm, 'β’ ') // convert list markers to bullets
|
| 9 |
.trim()
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
return (
|
| 12 |
-
<div className="mt-
|
| 13 |
-
<div className="flex items-
|
| 14 |
-
<span className="text-xs bg-indigo-200/80 text-indigo-800 px-
|
| 15 |
AI Summary
|
| 16 |
</span>
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
</div>
|
| 20 |
)
|
|
|
|
| 8 |
.replace(/^\s*[-*]\s/gm, 'β’ ') // convert list markers to bullets
|
| 9 |
.trim()
|
| 10 |
|
| 11 |
+
// Split into paragraphs by blank lines
|
| 12 |
+
const paragraphs = cleanText.split(/\n\s*\n/).filter(p => p.trim())
|
| 13 |
+
|
| 14 |
return (
|
| 15 |
+
<div className="mt-4 bg-indigo-50/70 backdrop-blur-sm border border-indigo-100/50 rounded-xl px-5 py-4">
|
| 16 |
+
<div className="flex items-center gap-2 mb-3">
|
| 17 |
+
<span className="text-xs bg-indigo-200/80 text-indigo-800 px-2 py-0.5 rounded-md font-semibold tracking-wide">
|
| 18 |
AI Summary
|
| 19 |
</span>
|
| 20 |
+
</div>
|
| 21 |
+
<div className="space-y-3">
|
| 22 |
+
{paragraphs.map((para, i) => (
|
| 23 |
+
<p key={i} className="text-sm text-gray-700 leading-relaxed">{para}</p>
|
| 24 |
+
))}
|
| 25 |
</div>
|
| 26 |
</div>
|
| 27 |
)
|
frontend/src/components/layout/Sidebar.jsx
CHANGED
|
@@ -14,7 +14,7 @@ export default function Sidebar() {
|
|
| 14 |
<aside className="w-64 min-h-screen bg-gray-900 text-white flex flex-col">
|
| 15 |
<div className="p-6 border-b border-gray-700">
|
| 16 |
<h1 className="text-lg font-bold tracking-tight">SimPPL Dashboard</h1>
|
| 17 |
-
<p className="text-xs text-gray-400 mt-1">
|
| 18 |
</div>
|
| 19 |
|
| 20 |
<nav className="flex-1 py-4">
|
|
|
|
| 14 |
<aside className="w-64 min-h-screen bg-gray-900 text-white flex flex-col">
|
| 15 |
<div className="p-6 border-b border-gray-700">
|
| 16 |
<h1 className="text-lg font-bold tracking-tight">SimPPL Dashboard</h1>
|
| 17 |
+
<p className="text-xs text-gray-400 mt-1">Tracing Narratives Across Reddit</p>
|
| 18 |
</div>
|
| 19 |
|
| 20 |
<nav className="flex-1 py-4">
|
frontend/src/components/layout/TopNavbar.jsx
CHANGED
|
@@ -6,6 +6,7 @@ const navItems = [
|
|
| 6 |
{ path: '/dashboard/timeseries', label: 'Time Series' },
|
| 7 |
{ path: '/dashboard/network', label: 'Network' },
|
| 8 |
{ path: '/dashboard/clusters', label: 'Topics' },
|
|
|
|
| 9 |
{ path: '/dashboard/search', label: 'SearchAI' },
|
| 10 |
{ path: '/dashboard/embeddings', label: 'Embeddings' },
|
| 11 |
]
|
|
@@ -25,7 +26,7 @@ export default function TopNavbar({ darkMode, setDarkMode }) {
|
|
| 25 |
TheScope
|
| 26 |
</h1>
|
| 27 |
<p className={`text-[10px] leading-tight ${darkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
| 28 |
-
|
| 29 |
</p>
|
| 30 |
</div>
|
| 31 |
</Link>
|
|
|
|
| 6 |
{ path: '/dashboard/timeseries', label: 'Time Series' },
|
| 7 |
{ path: '/dashboard/network', label: 'Network' },
|
| 8 |
{ path: '/dashboard/clusters', label: 'Topics' },
|
| 9 |
+
{ path: '/dashboard/compare', label: 'Compare' },
|
| 10 |
{ path: '/dashboard/search', label: 'SearchAI' },
|
| 11 |
{ path: '/dashboard/embeddings', label: 'Embeddings' },
|
| 12 |
]
|
|
|
|
| 26 |
TheScope
|
| 27 |
</h1>
|
| 28 |
<p className={`text-[10px] leading-tight ${darkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
| 29 |
+
Tracing Narratives Across Reddit
|
| 30 |
</p>
|
| 31 |
</div>
|
| 32 |
</Link>
|
frontend/src/pages/Clusters.jsx
CHANGED
|
@@ -197,6 +197,12 @@ export default function Clusters() {
|
|
| 197 |
</a>
|
| 198 |
))}
|
| 199 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
| 201 |
</div>
|
| 202 |
|
|
|
|
| 197 |
</a>
|
| 198 |
))}
|
| 199 |
</div>
|
| 200 |
+
{(cluster.top_posts || []).some(p => p.subreddit === 'worldpolitics') && (
|
| 201 |
+
<p className="text-[10px] text-amber-700/80 mt-2 italic leading-snug">
|
| 202 |
+
Note: this cluster includes posts from r/worldpolitics, a largely unmoderated
|
| 203 |
+
community that has drifted away from political discussion. Posts are shown as-is.
|
| 204 |
+
</p>
|
| 205 |
+
)}
|
| 206 |
</div>
|
| 207 |
</div>
|
| 208 |
|
frontend/src/pages/Compare.jsx
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import { getCompareSubreddits } from '../services/api'
|
| 3 |
+
import LoadingSpinner from '../components/common/LoadingSpinner'
|
| 4 |
+
import AISummary from '../components/common/AISummary'
|
| 5 |
+
import {
|
| 6 |
+
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend
|
| 7 |
+
} from 'recharts'
|
| 8 |
+
|
| 9 |
+
const SUBREDDITS = [
|
| 10 |
+
'Anarchism', 'socialism', 'democrats', 'Liberal', 'politics',
|
| 11 |
+
'PoliticalDiscussion', 'neoliberal', 'worldpolitics', 'Conservative', 'Republican'
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
const SUB_COLORS = {
|
| 15 |
+
Anarchism: '#dc2626', socialism: '#ef4444', democrats: '#3b82f6',
|
| 16 |
+
Liberal: '#60a5fa', politics: '#8b5cf6', PoliticalDiscussion: '#a78bfa',
|
| 17 |
+
neoliberal: '#6366f1', worldpolitics: '#14b8a6', Conservative: '#f97316', Republican: '#ea580c'
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function Compare() {
|
| 21 |
+
const [sub1, setSub1] = useState('Conservative')
|
| 22 |
+
const [sub2, setSub2] = useState('socialism')
|
| 23 |
+
const [data, setData] = useState(null)
|
| 24 |
+
const [loading, setLoading] = useState(true)
|
| 25 |
+
const [error, setError] = useState(null)
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (sub1 === sub2) return
|
| 29 |
+
setLoading(true)
|
| 30 |
+
setError(null)
|
| 31 |
+
getCompareSubreddits(sub1, sub2)
|
| 32 |
+
.then(res => setData(res.data))
|
| 33 |
+
.catch(err => setError(err.response?.data?.message || 'Failed to load comparison'))
|
| 34 |
+
.finally(() => setLoading(false))
|
| 35 |
+
}, [sub1, sub2])
|
| 36 |
+
|
| 37 |
+
// Merge timeseries for chart
|
| 38 |
+
const mergedTimeseries = (() => {
|
| 39 |
+
if (!data) return []
|
| 40 |
+
const dateMap = {}
|
| 41 |
+
for (const item of data.sub1.timeseries || []) {
|
| 42 |
+
if (!dateMap[item.date]) dateMap[item.date] = { date: item.date }
|
| 43 |
+
dateMap[item.date][data.sub1.name] = item.count
|
| 44 |
+
}
|
| 45 |
+
for (const item of data.sub2.timeseries || []) {
|
| 46 |
+
if (!dateMap[item.date]) dateMap[item.date] = { date: item.date }
|
| 47 |
+
dateMap[item.date][data.sub2.name] = item.count
|
| 48 |
+
}
|
| 49 |
+
return Object.values(dateMap).sort((a, b) => a.date.localeCompare(b.date))
|
| 50 |
+
})()
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div>
|
| 54 |
+
<h1 className="text-3xl font-bold text-gray-900 mb-2">Compare Communities</h1>
|
| 55 |
+
<p className="text-gray-500 mb-6 max-w-3xl">
|
| 56 |
+
Side-by-side comparison of two subreddits. See how each community discusses different topics, shares
|
| 57 |
+
different news sources, and engages at different rates. Pick any two subreddits below.
|
| 58 |
+
</p>
|
| 59 |
+
|
| 60 |
+
{/* Picker */}
|
| 61 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm p-5 mb-6">
|
| 62 |
+
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-4">
|
| 63 |
+
<div>
|
| 64 |
+
<label className="block text-xs text-gray-500 font-medium mb-1.5">Community A</label>
|
| 65 |
+
<select value={sub1} onChange={e => setSub1(e.target.value)}
|
| 66 |
+
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent bg-white">
|
| 67 |
+
{SUBREDDITS.map(s => (
|
| 68 |
+
<option key={s} value={s}>r/{s}</option>
|
| 69 |
+
))}
|
| 70 |
+
</select>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="flex items-center justify-center pt-5">
|
| 74 |
+
<div className="px-3 py-1 bg-gray-100 rounded-full text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
| 75 |
+
vs
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div>
|
| 80 |
+
<label className="block text-xs text-gray-500 font-medium mb-1.5">Community B</label>
|
| 81 |
+
<select value={sub2} onChange={e => setSub2(e.target.value)}
|
| 82 |
+
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent bg-white">
|
| 83 |
+
{SUBREDDITS.map(s => (
|
| 84 |
+
<option key={s} value={s}>r/{s}</option>
|
| 85 |
+
))}
|
| 86 |
+
</select>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
{sub1 === sub2 && (
|
| 90 |
+
<p className="text-xs text-amber-600 mt-3">Please select two different subreddits to compare.</p>
|
| 91 |
+
)}
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
{error && (
|
| 95 |
+
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
| 96 |
+
<p className="text-sm text-red-700">{error}</p>
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
{loading ? <LoadingSpinner message="Loading comparison..." /> : data && (
|
| 101 |
+
<>
|
| 102 |
+
{/* Side-by-side stats grid */}
|
| 103 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mb-6">
|
| 104 |
+
{[data.sub1, data.sub2].map((sub, idx) => (
|
| 105 |
+
<div key={sub.name} className="bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm overflow-hidden">
|
| 106 |
+
{/* Header */}
|
| 107 |
+
<div className="p-5 border-b border-gray-200/50"
|
| 108 |
+
style={{
|
| 109 |
+
background: `linear-gradient(135deg, ${SUB_COLORS[sub.name]}10 0%, transparent 100%)`
|
| 110 |
+
}}>
|
| 111 |
+
<div className="flex items-center gap-2 mb-1">
|
| 112 |
+
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: SUB_COLORS[sub.name] }} />
|
| 113 |
+
<h2 className="text-xl font-bold text-gray-900">r/{sub.name}</h2>
|
| 114 |
+
</div>
|
| 115 |
+
<p className="text-xs text-gray-500">{sub.unique_authors} unique authors</p>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
{/* Metrics */}
|
| 119 |
+
<div className="grid grid-cols-3 border-b border-gray-200/50">
|
| 120 |
+
<div className="p-4 border-r border-gray-200/50">
|
| 121 |
+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Posts</p>
|
| 122 |
+
<p className="text-2xl font-bold text-gray-900">{sub.total_posts.toLocaleString()}</p>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="p-4 border-r border-gray-200/50">
|
| 125 |
+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Avg Score</p>
|
| 126 |
+
<p className="text-2xl font-bold text-gray-900">{sub.avg_score.toLocaleString()}</p>
|
| 127 |
+
</div>
|
| 128 |
+
<div className="p-4">
|
| 129 |
+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Avg Comments</p>
|
| 130 |
+
<p className="text-2xl font-bold text-gray-900">{sub.avg_comments.toLocaleString()}</p>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Top news sources */}
|
| 135 |
+
<div className="p-5 border-b border-gray-200/50">
|
| 136 |
+
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3">Top News Sources</h3>
|
| 137 |
+
<div className="space-y-1.5">
|
| 138 |
+
{sub.top_domains.slice(0, 5).map(d => (
|
| 139 |
+
<div key={d.domain} className="flex items-center justify-between text-sm">
|
| 140 |
+
<span className="text-gray-700 truncate">{d.domain}</span>
|
| 141 |
+
<span className="text-xs text-gray-400 ml-2 shrink-0">{d.count}</span>
|
| 142 |
+
</div>
|
| 143 |
+
))}
|
| 144 |
+
{sub.top_domains.length === 0 && (
|
| 145 |
+
<p className="text-xs text-gray-400">No external links shared</p>
|
| 146 |
+
)}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
{/* Top topics */}
|
| 151 |
+
<div className="p-5 border-b border-gray-200/50">
|
| 152 |
+
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3">Top Discussion Topics</h3>
|
| 153 |
+
<div className="space-y-1.5">
|
| 154 |
+
{sub.top_topics.slice(0, 5).map((t, i) => (
|
| 155 |
+
<div key={i} className="flex items-center justify-between text-sm">
|
| 156 |
+
<span className="text-gray-700 text-xs truncate" title={t.label}>{t.label}</span>
|
| 157 |
+
<span className="text-xs text-gray-400 ml-2 shrink-0">{t.count}</span>
|
| 158 |
+
</div>
|
| 159 |
+
))}
|
| 160 |
+
{sub.top_topics.length === 0 && (
|
| 161 |
+
<p className="text-xs text-gray-400">No topic data available</p>
|
| 162 |
+
)}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Top authors */}
|
| 167 |
+
<div className="p-5 border-b border-gray-200/50">
|
| 168 |
+
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3">Most Active Authors</h3>
|
| 169 |
+
<div className="space-y-1.5">
|
| 170 |
+
{sub.top_authors.slice(0, 5).map(a => (
|
| 171 |
+
<div key={a.author} className="flex items-center justify-between text-sm">
|
| 172 |
+
<a href={`https://reddit.com/u/${a.author}`} target="_blank" rel="noopener noreferrer"
|
| 173 |
+
className="text-indigo-600 hover:text-indigo-800 hover:underline truncate">
|
| 174 |
+
u/{a.author}
|
| 175 |
+
</a>
|
| 176 |
+
<span className="text-xs text-gray-400 ml-2 shrink-0">{a.count} posts</span>
|
| 177 |
+
</div>
|
| 178 |
+
))}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
{/* Top posts */}
|
| 183 |
+
<div className="p-5">
|
| 184 |
+
<h3 className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-3">Top Posts</h3>
|
| 185 |
+
{sub.top_posts.length > 0 ? (
|
| 186 |
+
<div className="space-y-2">
|
| 187 |
+
{sub.top_posts.slice(0, 3).map(p => (
|
| 188 |
+
<a key={p.id} href={p.permalink ? `https://reddit.com${p.permalink}` : '#'}
|
| 189 |
+
target="_blank" rel="noopener noreferrer"
|
| 190 |
+
className="block text-xs text-gray-600 hover:text-indigo-600 line-clamp-2">
|
| 191 |
+
{p.title} <span className="text-gray-400">({p.score.toLocaleString()} upvotes)</span>
|
| 192 |
+
</a>
|
| 193 |
+
))}
|
| 194 |
+
</div>
|
| 195 |
+
) : (
|
| 196 |
+
<p className="text-xs text-gray-400">No posts to display.</p>
|
| 197 |
+
)}
|
| 198 |
+
{sub.name === 'worldpolitics' && (
|
| 199 |
+
<p className="text-[10px] text-amber-700/80 mt-3 italic leading-snug">
|
| 200 |
+
Note: r/worldpolitics is largely unmoderated and has drifted away from political discussion.
|
| 201 |
+
Posts are shown as-is from the source data.
|
| 202 |
+
</p>
|
| 203 |
+
)}
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
))}
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{/* Time series chart */}
|
| 210 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm p-6 mb-6">
|
| 211 |
+
<h2 className="text-lg font-semibold text-gray-900 mb-1">Activity Over Time</h2>
|
| 212 |
+
<p className="text-sm text-gray-500 mb-4">
|
| 213 |
+
Weekly post volume for both communities side by side
|
| 214 |
+
</p>
|
| 215 |
+
<ResponsiveContainer width="100%" height={350}>
|
| 216 |
+
<LineChart data={mergedTimeseries}>
|
| 217 |
+
<XAxis dataKey="date" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
| 218 |
+
<YAxis tick={{ fontSize: 11 }} />
|
| 219 |
+
<Tooltip />
|
| 220 |
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
| 221 |
+
<Line type="monotone" dataKey={data.sub1.name} stroke={SUB_COLORS[data.sub1.name]}
|
| 222 |
+
strokeWidth={2} dot={false} connectNulls />
|
| 223 |
+
<Line type="monotone" dataKey={data.sub2.name} stroke={SUB_COLORS[data.sub2.name]}
|
| 224 |
+
strokeWidth={2} dot={false} connectNulls />
|
| 225 |
+
</LineChart>
|
| 226 |
+
</ResponsiveContainer>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{/* AI Summary */}
|
| 230 |
+
<AISummary text={data.summary} />
|
| 231 |
+
</>
|
| 232 |
+
)}
|
| 233 |
+
</div>
|
| 234 |
+
)
|
| 235 |
+
}
|
frontend/src/pages/Embeddings.jsx
CHANGED
|
@@ -1,38 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function Embeddings() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
return (
|
| 3 |
-
<div
|
| 4 |
-
<div className="flex items-
|
| 5 |
<div>
|
| 6 |
<h1 className="text-3xl font-bold text-gray-900">Embedding Explorer</h1>
|
| 7 |
-
<p className="text-gray-500 mt-1">
|
| 8 |
-
|
| 9 |
-
|
| 10 |
</p>
|
| 11 |
</div>
|
| 12 |
-
<div className="flex items-center gap-3 text-xs text-gray-500 bg-gray-100 px-3 py-2 rounded-lg">
|
| 13 |
<span><strong>Scroll</strong> to zoom</span>
|
| 14 |
<span>Β·</span>
|
| 15 |
<span><strong>Drag</strong> to pan</span>
|
| 16 |
<span>Β·</span>
|
| 17 |
<span><strong>Hover</strong> for details</span>
|
| 18 |
-
<span>Β·</span>
|
| 19 |
-
<span>Use the <strong>search bar</strong> inside the map</span>
|
| 20 |
</div>
|
| 21 |
</div>
|
| 22 |
|
| 23 |
-
<div className="
|
| 24 |
<iframe
|
| 25 |
src="/static/datamapplot.html"
|
| 26 |
title="Embedding Visualization"
|
| 27 |
className="w-full border-0"
|
| 28 |
-
style={{ height: '
|
| 29 |
/>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
-
<p className="text-xs text-gray-400
|
| 33 |
UMAP 2D projection of all-MiniLM-L6-v2 sentence embeddings (384-dim β 2D).
|
| 34 |
Colored by subreddit. Built with Datamapplot.
|
| 35 |
</p>
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
)
|
| 38 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react'
|
| 2 |
+
import { getEmbeddingsSummary } from '../services/api'
|
| 3 |
+
import AISummary from '../components/common/AISummary'
|
| 4 |
+
|
| 5 |
export default function Embeddings() {
|
| 6 |
+
const [summary, setSummary] = useState('')
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
getEmbeddingsSummary()
|
| 10 |
+
.then(res => setSummary(res.data.summary || ''))
|
| 11 |
+
.catch(err => console.error(err))
|
| 12 |
+
}, [])
|
| 13 |
+
|
| 14 |
return (
|
| 15 |
+
<div>
|
| 16 |
+
<div className="flex items-start justify-between mb-3 gap-4">
|
| 17 |
<div>
|
| 18 |
<h1 className="text-3xl font-bold text-gray-900">Embedding Explorer</h1>
|
| 19 |
+
<p className="text-gray-500 mt-1 max-w-2xl">
|
| 20 |
+
A visual map of all 8,799 posts. Posts that discuss similar things are placed near each other β
|
| 21 |
+
tight clumps reveal topic neighborhoods that emerged automatically from the data.
|
| 22 |
</p>
|
| 23 |
</div>
|
| 24 |
+
<div className="flex items-center gap-3 text-xs text-gray-500 bg-gray-100 px-3 py-2 rounded-lg shrink-0">
|
| 25 |
<span><strong>Scroll</strong> to zoom</span>
|
| 26 |
<span>Β·</span>
|
| 27 |
<span><strong>Drag</strong> to pan</span>
|
| 28 |
<span>Β·</span>
|
| 29 |
<span><strong>Hover</strong> for details</span>
|
|
|
|
|
|
|
| 30 |
</div>
|
| 31 |
</div>
|
| 32 |
|
| 33 |
+
<div className="bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm overflow-hidden mb-4">
|
| 34 |
<iframe
|
| 35 |
src="/static/datamapplot.html"
|
| 36 |
title="Embedding Visualization"
|
| 37 |
className="w-full border-0"
|
| 38 |
+
style={{ height: '70vh', minHeight: 500 }}
|
| 39 |
/>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
+
<p className="text-xs text-gray-400 mb-4">
|
| 43 |
UMAP 2D projection of all-MiniLM-L6-v2 sentence embeddings (384-dim β 2D).
|
| 44 |
Colored by subreddit. Built with Datamapplot.
|
| 45 |
</p>
|
| 46 |
+
|
| 47 |
+
<AISummary text={summary} />
|
| 48 |
</div>
|
| 49 |
)
|
| 50 |
}
|
frontend/src/pages/Landing.jsx
CHANGED
|
@@ -149,7 +149,7 @@ export default function Landing() {
|
|
| 149 |
<div className="text-amber-400 text-xs font-medium tracking-[0.2em] uppercase mb-4">What you can explore</div>
|
| 150 |
<h2 className="text-4xl md:text-5xl font-bold leading-tight"
|
| 151 |
style={{ fontFamily: "'DM Serif Display', Georgia, serif" }}>
|
| 152 |
-
Six lenses on
|
| 153 |
</h2>
|
| 154 |
<p className="text-gray-500 mt-4 text-base">
|
| 155 |
Each section is built around a specific question β from how communities discussed events over time, to who bridges them, to how topics evolved.
|
|
@@ -228,7 +228,7 @@ export default function Landing() {
|
|
| 228 |
Ready to investigate?
|
| 229 |
</h3>
|
| 230 |
<p className="text-gray-400 mb-8">
|
| 231 |
-
Step into the dashboard and start
|
| 232 |
</p>
|
| 233 |
<button onClick={() => navigate('/dashboard')}
|
| 234 |
className="px-10 py-4 bg-gradient-to-r from-amber-500 to-orange-600 text-white font-semibold rounded-full shadow-2xl shadow-amber-500/30 hover:shadow-amber-500/50 hover:scale-105 transition-all text-lg">
|
|
|
|
| 149 |
<div className="text-amber-400 text-xs font-medium tracking-[0.2em] uppercase mb-4">What you can explore</div>
|
| 150 |
<h2 className="text-4xl md:text-5xl font-bold leading-tight"
|
| 151 |
style={{ fontFamily: "'DM Serif Display', Georgia, serif" }}>
|
| 152 |
+
Six lenses on a single Reddit dataset
|
| 153 |
</h2>
|
| 154 |
<p className="text-gray-500 mt-4 text-base">
|
| 155 |
Each section is built around a specific question β from how communities discussed events over time, to who bridges them, to how topics evolved.
|
|
|
|
| 228 |
Ready to investigate?
|
| 229 |
</h3>
|
| 230 |
<p className="text-gray-400 mb-8">
|
| 231 |
+
Step into the dashboard and start tracing how narratives moved through Reddit.
|
| 232 |
</p>
|
| 233 |
<button onClick={() => navigate('/dashboard')}
|
| 234 |
className="px-10 py-4 bg-gradient-to-r from-amber-500 to-orange-600 text-white font-semibold rounded-full shadow-2xl shadow-amber-500/30 hover:shadow-amber-500/50 hover:scale-105 transition-all text-lg">
|
frontend/src/pages/Network.jsx
CHANGED
|
@@ -144,15 +144,16 @@ export default function Network() {
|
|
| 144 |
return (
|
| 145 |
<div>
|
| 146 |
<h1 className="text-3xl font-bold text-gray-900 mb-2">Network Analysis</h1>
|
| 147 |
-
<p className="text-gray-500 mb-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
3 edge types: crosspost links (base weight 3.0), shared URL co-sharing (base weight 2.0), co-subreddit activity (base weight 1.0). Weights accumulate for repeated interactions. [deleted] accounts excluded to prevent false super-connectors.
|
| 154 |
</p>
|
| 155 |
|
|
|
|
|
|
|
| 156 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 157 |
{/* Graph */}
|
| 158 |
<div className="lg:col-span-2 bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm p-4" ref={containerRef}>
|
|
|
|
| 144 |
return (
|
| 145 |
<div>
|
| 146 |
<h1 className="text-3xl font-bold text-gray-900 mb-2">Network Analysis</h1>
|
| 147 |
+
<p className="text-gray-500 mb-3 max-w-3xl">
|
| 148 |
+
Each dot is a Reddit author. Two authors are connected if they interact β
|
| 149 |
+
by sharing the same news link, posting in the same communities, or one crossposting from the other.
|
| 150 |
+
Bigger dots are more influential (they sit on more pathways through the network).
|
| 151 |
+
Colors group authors who tend to cluster together. Click any author to see who they connect to,
|
| 152 |
+
or remove them to see if the network falls apart without them.
|
|
|
|
| 153 |
</p>
|
| 154 |
|
| 155 |
+
<p className="text-xs text-gray-400 mb-6 font-medium">How the network is built</p>
|
| 156 |
+
|
| 157 |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 158 |
{/* Graph */}
|
| 159 |
<div className="lg:col-span-2 bg-white/70 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm p-4" ref={containerRef}>
|
frontend/src/pages/Overview.jsx
CHANGED
|
@@ -72,15 +72,22 @@ export default function Overview() {
|
|
| 72 |
|
| 73 |
return (
|
| 74 |
<div>
|
| 75 |
-
<div className="mb-8">
|
| 76 |
<h1 className="text-3xl font-bold text-gray-900">
|
| 77 |
-
|
| 78 |
</h1>
|
| 79 |
-
<p className="text-gray-500 mt-
|
| 80 |
-
How do
|
| 81 |
-
This
|
| 82 |
-
across
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</p>
|
| 85 |
</div>
|
| 86 |
|
|
@@ -91,7 +98,7 @@ export default function Overview() {
|
|
| 91 |
</summary>
|
| 92 |
<div className="px-6 pb-6 text-gray-600 space-y-4 border-t border-gray-100 pt-5">
|
| 93 |
<p className="text-sm leading-relaxed">
|
| 94 |
-
This dashboard investigates how
|
| 95 |
and 2025 presidential transition. Inspired by SimPPL's Parrot platform, it combines <strong className="text-gray-800">linguistic analysis</strong> (NLP)
|
| 96 |
with <strong className="text-gray-800">network analysis</strong> to trace how information flows between communities with different political orientations.
|
| 97 |
</p>
|
|
@@ -99,10 +106,23 @@ export default function Overview() {
|
|
| 99 |
<div className="bg-white/10 rounded-lg p-4 border border-gray-200/20">
|
| 100 |
<h4 className="font-semibold text-gray-900 mb-2 text-sm">Data Source</h4>
|
| 101 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 102 |
-
8,799 Reddit posts from 10 subreddits
|
| 103 |
-
r/
|
| 104 |
-
Each post includes title, body text, author, score,
|
|
|
|
| 105 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
<div className="bg-white/10 rounded-lg p-4 border border-gray-200/20">
|
| 108 |
<h4 className="font-semibold text-gray-900 mb-2 text-sm">NLP Pipeline</h4>
|
|
|
|
| 72 |
|
| 73 |
return (
|
| 74 |
<div>
|
| 75 |
+
<div className="mb-8 max-w-4xl">
|
| 76 |
<h1 className="text-3xl font-bold text-gray-900">
|
| 77 |
+
Tracing Narratives Across Reddit Communities
|
| 78 |
</h1>
|
| 79 |
+
<p className="text-gray-500 mt-3 leading-relaxed">
|
| 80 |
+
How do politically distinct Reddit communities process the same events β and what does each one notice
|
| 81 |
+
that the others miss? This dashboard traces {stats.total_posts.toLocaleString()} posts from {stats.total_authors.toLocaleString()} authors
|
| 82 |
+
across 10 subreddits between July 2024 and February 2025 β a window that covers Biden dropping out,
|
| 83 |
+
the November election, and the first month of Trump's second term.
|
| 84 |
+
</p>
|
| 85 |
+
<p className="text-gray-500 mt-3 leading-relaxed">
|
| 86 |
+
The most striking pattern in the data is concentration: 83% of all activity falls into the six weeks
|
| 87 |
+
after January 20, when daily volume jumped from roughly 13 posts/day to 217 β a 1,500% surge that
|
| 88 |
+
turned the second half of the dataset into a near-real-time snapshot of the transition itself.
|
| 89 |
+
The pages below trace what each community noticed, which accounts bridged them, and how the topics
|
| 90 |
+
shifted week by week.
|
| 91 |
</p>
|
| 92 |
</div>
|
| 93 |
|
|
|
|
| 98 |
</summary>
|
| 99 |
<div className="px-6 pb-6 text-gray-600 space-y-4 border-t border-gray-100 pt-5">
|
| 100 |
<p className="text-sm leading-relaxed">
|
| 101 |
+
This dashboard investigates how narratives spread across a curated set of Reddit communities during the 2024 US election
|
| 102 |
and 2025 presidential transition. Inspired by SimPPL's Parrot platform, it combines <strong className="text-gray-800">linguistic analysis</strong> (NLP)
|
| 103 |
with <strong className="text-gray-800">network analysis</strong> to trace how information flows between communities with different political orientations.
|
| 104 |
</p>
|
|
|
|
| 106 |
<div className="bg-white/10 rounded-lg p-4 border border-gray-200/20">
|
| 107 |
<h4 className="font-semibold text-gray-900 mb-2 text-sm">Data Source</h4>
|
| 108 |
<p className="text-sm text-gray-600 leading-relaxed">
|
| 109 |
+
8,799 Reddit posts from 10 subreddits collected for their political associations:
|
| 110 |
+
r/Anarchism, r/socialism, r/democrats, r/Liberal, r/politics, r/PoliticalDiscussion, r/neoliberal,
|
| 111 |
+
r/Conservative, r/Republican, and r/worldpolitics. Each post includes title, body text, author, score,
|
| 112 |
+
comments, and timestamps (July 23, 2024 β February 18, 2025).
|
| 113 |
</p>
|
| 114 |
+
<div className="mt-3 bg-amber-50/70 border border-amber-200/70 rounded-lg px-3 py-2.5">
|
| 115 |
+
<div className="flex items-start gap-2">
|
| 116 |
+
<div className="shrink-0 mt-0.5 w-4 h-4 rounded-full bg-amber-200/70 text-amber-800 flex items-center justify-center text-[10px] font-bold">!</div>
|
| 117 |
+
<p className="text-xs text-amber-900/90 leading-relaxed">
|
| 118 |
+
<strong className="font-semibold">Noted during the dataset audit:</strong> nine of the ten subreddits
|
| 119 |
+
contain active political discussion, while r/worldpolitics has drifted into largely unmoderated,
|
| 120 |
+
off-topic content (every post is flagged NSFW in the source data). It is kept in all analyses because
|
| 121 |
+
removing it would change the network topology and weaken the comparison; individual r/worldpolitics
|
| 122 |
+
posts are marked with a contextual note wherever they appear in the UI.
|
| 123 |
+
</p>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
</div>
|
| 127 |
<div className="bg-white/10 rounded-lg p-4 border border-gray-200/20">
|
| 128 |
<h4 className="font-semibold text-gray-900 mb-2 text-sm">NLP Pipeline</h4>
|
frontend/src/pages/Search.jsx
CHANGED
|
@@ -123,6 +123,12 @@ export default function Search() {
|
|
| 123 |
<p className="text-xs text-gray-500 font-medium mb-2">
|
| 124 |
Top {msg.results.length} results
|
| 125 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<div className="space-y-2">
|
| 127 |
{msg.results.slice(0, 10).map((r, j) => (
|
| 128 |
<a key={r.id} href={r.permalink ? `https://reddit.com${r.permalink}` : '#'}
|
|
@@ -187,7 +193,7 @@ export default function Search() {
|
|
| 187 |
type="text"
|
| 188 |
value={input}
|
| 189 |
onChange={e => setInput(e.target.value)}
|
| 190 |
-
placeholder="Ask anything about political
|
| 191 |
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
| 192 |
disabled={loading}
|
| 193 |
/>
|
|
|
|
| 123 |
<p className="text-xs text-gray-500 font-medium mb-2">
|
| 124 |
Top {msg.results.length} results
|
| 125 |
</p>
|
| 126 |
+
{msg.results.some(r => r.subreddit === 'worldpolitics') && (
|
| 127 |
+
<p className="text-[11px] text-amber-700/90 bg-amber-50/60 border border-amber-200/60 rounded px-2.5 py-1.5 mb-3 italic leading-snug">
|
| 128 |
+
Some results are from r/worldpolitics, a largely unmoderated community that has
|
| 129 |
+
drifted away from political discussion. Posts are shown as-is from the source data.
|
| 130 |
+
</p>
|
| 131 |
+
)}
|
| 132 |
<div className="space-y-2">
|
| 133 |
{msg.results.slice(0, 10).map((r, j) => (
|
| 134 |
<a key={r.id} href={r.permalink ? `https://reddit.com${r.permalink}` : '#'}
|
|
|
|
| 193 |
type="text"
|
| 194 |
value={input}
|
| 195 |
onChange={e => setInput(e.target.value)}
|
| 196 |
+
placeholder="Ask anything about Reddit's political communities..."
|
| 197 |
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
| 198 |
disabled={loading}
|
| 199 |
/>
|
frontend/src/pages/TimeSeries.jsx
CHANGED
|
@@ -104,7 +104,7 @@ export default function TimeSeries() {
|
|
| 104 |
<div>
|
| 105 |
<h1 className="text-3xl font-bold text-gray-900 mb-2">Time Series Analysis</h1>
|
| 106 |
<p className="text-gray-500 mb-6">
|
| 107 |
-
Track how
|
| 108 |
increases for one subreddit but not others, it may indicate community-specific narratives.
|
| 109 |
</p>
|
| 110 |
|
|
|
|
| 104 |
<div>
|
| 105 |
<h1 className="text-3xl font-bold text-gray-900 mb-2">Time Series Analysis</h1>
|
| 106 |
<p className="text-gray-500 mb-6">
|
| 107 |
+
Track how activity across the 10 communities in the dataset evolves over time. Look for spikes that correlate with real-world events β when volume
|
| 108 |
increases for one subreddit but not others, it may indicate community-specific narratives.
|
| 109 |
</p>
|
| 110 |
|
frontend/src/services/api.js
CHANGED
|
@@ -47,4 +47,12 @@ export const generateSummary = (data) =>
|
|
| 47 |
export const getOverviewStats = () =>
|
| 48 |
api.get('/overview/stats')
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
export default api
|
|
|
|
| 47 |
export const getOverviewStats = () =>
|
| 48 |
api.get('/overview/stats')
|
| 49 |
|
| 50 |
+
// Embeddings summary
|
| 51 |
+
export const getEmbeddingsSummary = () =>
|
| 52 |
+
api.get('/embeddings/summary')
|
| 53 |
+
|
| 54 |
+
// Compare two subreddits
|
| 55 |
+
export const getCompareSubreddits = (sub1, sub2) =>
|
| 56 |
+
api.get('/compare', { params: { sub1, sub2 } })
|
| 57 |
+
|
| 58 |
export default api
|
vedant-prompts.md
CHANGED
|
@@ -168,10 +168,53 @@ The final feature work was adding the narrative content β the methodology sect
|
|
| 168 |
|
| 169 |
### Stage 7: Deployment
|
| 170 |
|
| 171 |
-
With everything stress-tested and working locally, the
|
| 172 |
|
| 173 |
## Prompt 16
|
| 174 |
**Component**: Deployment configuration (`Dockerfile`)
|
| 175 |
**Prompt**: "Configure a Dockerfile for Hugging Face Spaces β Python 3.11, Node.js 22, build the React frontend, run the data pipeline during Docker build, serve with gunicorn on port 7860."
|
| 176 |
**Issue**: Initial deployment on Render.com failed with out-of-memory on the 512MB free tier β sentence-transformer model (~90MB) plus embeddings (13MB) plus Flask overhead exceeded the limit.
|
| 177 |
**Fix**: Switched to Hugging Face Spaces (16GB RAM on Docker free tier). Restructured Dockerfile to run the full pipeline during Docker build so large pre-computed files don't need to be committed (avoiding GitHub fork LFS restrictions).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
### Stage 7: Deployment
|
| 170 |
|
| 171 |
+
With everything stress-tested and working locally, the next step was deploying to a public URL. The first attempt on Render.com failed, leading to a switch to Hugging Face Spaces.
|
| 172 |
|
| 173 |
## Prompt 16
|
| 174 |
**Component**: Deployment configuration (`Dockerfile`)
|
| 175 |
**Prompt**: "Configure a Dockerfile for Hugging Face Spaces β Python 3.11, Node.js 22, build the React frontend, run the data pipeline during Docker build, serve with gunicorn on port 7860."
|
| 176 |
**Issue**: Initial deployment on Render.com failed with out-of-memory on the 512MB free tier β sentence-transformer model (~90MB) plus embeddings (13MB) plus Flask overhead exceeded the limit.
|
| 177 |
**Fix**: Switched to Hugging Face Spaces (16GB RAM on Docker free tier). Restructured Dockerfile to run the full pipeline during Docker build so large pre-computed files don't need to be committed (avoiding GitHub fork LFS restrictions).
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
### Stage 8: Compare Communities (post-launch addition)
|
| 182 |
+
|
| 183 |
+
After the dashboard was already deployed and working, the most striking insight from playing with the data was how differently each community discussed the same events. The existing pages let you explore one community at a time, but didn't make the contrast obvious. The Compare Communities page was added to fill that gap β letting a user pick any two of the 10 subreddits and see them side by side.
|
| 184 |
+
|
| 185 |
+
## Prompt 17
|
| 186 |
+
**Component**: Compare Communities backend endpoint (`backend/routes/overview.py`)
|
| 187 |
+
**Prompt**: "Add a /compare endpoint that takes two subreddit names and returns side-by-side stats for both: total posts, unique authors, average score and comments, top news domains, top authors, top topics from existing cluster assignments, top posts, and weekly time series. Use the existing cluster_assignments table for topics β don't recompute."
|
| 188 |
+
**Issue**: The first version recomputed clusters per subreddit on every request, which was slow and unnecessary since cluster assignments are already pre-computed for k=15 in the SQLite table.
|
| 189 |
+
**Fix**: Joined `posts` with `cluster_assignments` directly in SQL with `WHERE p.subreddit = ? AND c.k = 15` to read pre-computed cluster labels per post, group by label, and order by count. The whole comparison endpoint runs in under 100ms even with the LLM call. Also added validation for invalid subreddit names and same-subreddit comparison (returns 400 with a clear message).
|
| 190 |
+
|
| 191 |
+
## Prompt 18
|
| 192 |
+
**Component**: Compare Communities LLM analysis prompt (`backend/services/llm_service.py`)
|
| 193 |
+
**Prompt**: "Write an LLM prompt for generating a 4-paragraph analytical comparison of two subreddits. The four paragraphs should cover engagement comparison, information ecosystem (news sources), topical focus, and a journalist-ready takeaway. The output must reference specific numbers and domain names from the data, not be generic."
|
| 194 |
+
**Issue**: The first version of the prompt produced generic output like "These two communities have different perspectives on political issues" β true but useless. It wasn't anchored to the actual data points being passed in.
|
| 195 |
+
**Fix**: Restructured the prompt to require specific numbered claims for each paragraph and explicitly listed every data point the model should cite (top 5 domains by name, top 3 topics by keyword string, exact author counts). Also added the instruction "End with a concrete observation that a journalist could use as the seed for a story" β this dramatically improved the quality of the takeaway sentence by giving the model a clear audience.
|
| 196 |
+
|
| 197 |
+
## Prompt 19
|
| 198 |
+
**Component**: Compare Communities side-by-side React layout (`frontend/src/pages/Compare.jsx`)
|
| 199 |
+
**Prompt**: "Build a React page with two subreddit picker dropdowns separated by a 'VS' pill, then a side-by-side grid showing matching cards for each subreddit (metrics, top news, top topics, top authors, top posts), then an overlapping line chart of both communities' weekly post volume on the same axes, then an AI summary at the bottom."
|
| 200 |
+
**Issue**: The first layout used `grid-cols-2` for the side-by-side, but the cards inside had wildly different heights because some subreddits have more news domains than others. The result was uneven, ragged columns that looked broken.
|
| 201 |
+
**Fix**: Changed each side into a single bordered card with internal sections separated by `border-b`, so the visual structure stays uniform even when content lengths differ. Added a gradient header colored by the subreddit's signature color so users can immediately tell which side is which without reading the label. Also added click-through Reddit links on every author and post.
|
| 202 |
+
|
| 203 |
+
## Prompt 20
|
| 204 |
+
**Component**: Stretching AI summaries to be substantive (`backend/services/llm_service.py`)
|
| 205 |
+
**Prompt**: "My AI summaries on the time series, network, and overview pages are only 2-3 sentences with max_tokens=200. They're too short to actually help a non-technical reader understand the chart. Make them 4-6 sentences with proper context, specific numbers, and a takeaway."
|
| 206 |
+
**Issue**: Just bumping max_tokens didn't help β the model still wrote short responses because the prompts said "2-3 sentences." The instructions in the prompt itself were the bottleneck.
|
| 207 |
+
**Fix**: Rewrote each summary prompt to specify "5 to 6 sentences" and gave the model an explicit structure (sentence 1 = trend description, sentence 2 = peak explanation, sentence 3 = comparison, sentence 4 = inflection point, sentence 5-6 = takeaway). Also passed richer data β first-half vs second-half averages, peak contributors, percentage change β so the model had real material to work with. The summaries went from generic to specific and actually useful.
|
| 208 |
+
|
| 209 |
+
---
|
| 210 |
+
|
| 211 |
+
### Stage 9: r/worldpolitics & project framing
|
| 212 |
+
|
| 213 |
+
While clicking through the dashboard , I noticed something ,the r/worldpolitics is a 100% NSFW-flagged community whose top posts are mostly explicit images and off-topic content. The subreddit's name implies political discussion, but the actual data is the opposite β it's a textbook case of an unmoderated community drifting away from its original purpose. This raised two questions: how should the dashboard display this content, and is "Political Discourse Analysis Dashboard" still the right framing for the project?
|
| 214 |
+
|
| 215 |
+
## Prompt 21
|
| 216 |
+
**Component**: r/worldpolitics content handling (`backend/routes/overview.py`, `clusters.py`, `search.py`, `frontend/src/pages/Compare.jsx`, `Clusters.jsx`, `Search.jsx`, `README.md`)
|
| 217 |
+
**Prompt**: "r/worldpolitics has NSFW posts in its top results across the dashboard. Should I filter `over_18 = 1` from displayed posts everywhere?"
|
| 218 |
+
**Issue**: The first answer was to add blanket `AND over_18 = 0` filters across all four query paths and slap warning messages where posts disappeared. But hiding data from a research dashboard is editorializing β SimPPL's rubric explicitly mentions studying "actors and networks promoting... harassment" and the contrast between 9 moderated and 1 unmoderated political community is itself a finding worth surfacing, not something to censor.
|
| 219 |
+
**Fix**: Reverted every backend filter. Added a single contextual one-line note that appears only where r/worldpolitics content actually surfaces (Compare top posts, Clusters expanded panel, Search results) explaining the community is unmoderated and posts are shown as-is. Documented the explicit decision in a "A Note on r/worldpolitics" section in the README so it's a defensible engineering choice in the interview rather than silent filtering. Show raw data, frame the unmoderation as an analytical observation.
|
| 220 |
+
|