lization commited on
Commit
7131afa
·
verified ·
1 Parent(s): 2a615b8

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -399
app.py DELETED
@@ -1,399 +0,0 @@
1
- """
2
- Trend Longevity Analyser
3
- ========================
4
- A RAG-powered tool that classifies whether a topic/trend is Early, Rising,
5
- at Peak, Declining, or Fading — based on live news data.
6
-
7
- RAG Pipeline (6 steps):
8
- 1. FETCH → NewsAPI pulls up to 50 recent articles on the topic
9
- 2. CHUNK → LangChain splits each article into overlapping 400-char chunks
10
- 3. EMBED → Sentence Transformers encodes every chunk as a dense vector
11
- 4. STORE → ChromaDB holds all vectors in an in-memory collection
12
- 5. RETRIEVE → Semantic search returns the 10 most relevant chunks
13
- 6. GENERATE → Claude analyses the retrieved context and returns a structured signal
14
-
15
- Built by: Sammie Wong
16
- """
17
-
18
- import os
19
- import json
20
- import uuid
21
- import gradio as gr
22
- import anthropic
23
- import chromadb
24
- from chromadb.utils import embedding_functions
25
- from newsapi import NewsApiClient
26
- from langchain.text_splitter import RecursiveCharacterTextSplitter
27
- from datetime import datetime, timedelta
28
-
29
-
30
- # ─── STEP 1: FETCH ────────────────────────────────────────────────────────────
31
-
32
- def fetch_articles(topic: str, days_back: int, newsapi_key: str) -> list[dict]:
33
- """
34
- Pull recent news articles from NewsAPI for the given topic.
35
- Free tier: up to 100 requests/day, articles from the last 30 days.
36
- """
37
- client = NewsApiClient(api_key=newsapi_key)
38
- from_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
39
-
40
- response = client.get_everything(
41
- q=f'"{topic}"', # Exact phrase match for cleaner signal
42
- language='en',
43
- sort_by='publishedAt',
44
- from_param=from_date,
45
- page_size=50 # NewsAPI free tier maximum
46
- )
47
- return response.get('articles', [])
48
-
49
-
50
- # ─── STEP 2: CHUNK ────────────────────────────────────────────────────────────
51
-
52
- def chunk_articles(articles: list[dict]) -> tuple[list[str], list[dict]]:
53
- """
54
- Split each article into overlapping chunks.
55
-
56
- Why chunk? LLMs have context limits and RAG works better with focused
57
- pieces of text than entire articles. Overlap (40 chars) prevents
58
- losing meaning at chunk boundaries.
59
- """
60
- splitter = RecursiveCharacterTextSplitter(
61
- chunk_size=400, # ~80 words — enough context per chunk
62
- chunk_overlap=40 # Overlap prevents boundary information loss
63
- )
64
-
65
- all_chunks, all_metadata = [], []
66
-
67
- for article in articles:
68
- # Combine title + description + content for the richest signal
69
- full_text = " ".join(filter(None, [
70
- article.get('title', ''),
71
- article.get('description', ''),
72
- article.get('content', '')
73
- ]))
74
-
75
- for chunk in splitter.split_text(full_text):
76
- all_chunks.append(chunk)
77
- all_metadata.append({
78
- 'source': article['source']['name'],
79
- 'published_at': article['publishedAt'][:10],
80
- 'title': (article.get('title') or '')[:100]
81
- })
82
-
83
- return all_chunks, all_metadata
84
-
85
-
86
- # ─── STEPS 3 & 4: EMBED + STORE ───────────────────────────────────────────────
87
-
88
- def build_vector_store(chunks: list[str], metadatas: list[dict]):
89
- """
90
- Encode every chunk into a 384-dimensional embedding vector and store
91
- in an in-memory ChromaDB collection.
92
-
93
- Model: all-MiniLM-L6-v2 — fast, accurate, no GPU needed.
94
- Storage: EphemeralClient (in-memory) means no disk writes — perfect for
95
- a serverless HF Space where each request is stateless.
96
- """
97
- embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
98
- model_name="all-MiniLM-L6-v2"
99
- )
100
-
101
- try:
102
- db = chromadb.EphemeralClient() # ChromaDB >= 0.4.x
103
- except AttributeError:
104
- db = chromadb.Client() # Fallback for older versions
105
-
106
- collection = db.create_collection(
107
- name=f"trend_{uuid.uuid4().hex[:8]}",
108
- embedding_function=embedding_fn
109
- )
110
-
111
- # Insert in batches of 100 to avoid memory spikes
112
- batch_size = 100
113
- for i in range(0, len(chunks), batch_size):
114
- batch_end = i + batch_size
115
- collection.add(
116
- documents=chunks[i:batch_end],
117
- metadatas=metadatas[i:batch_end],
118
- ids=[f"c{i + j}" for j in range(len(chunks[i:batch_end]))]
119
- )
120
-
121
- return collection
122
-
123
-
124
- # ─── STEP 5: RETRIEVE ─────────────────────────────────────────────────────────
125
-
126
- def retrieve_context(collection, topic: str, n_results: int = 10) -> tuple[list[str], list[dict]]:
127
- """
128
- Run a semantic similarity search to retrieve the most relevant chunks.
129
-
130
- The query is phrased to surface trend-signal language — not just
131
- topic mentions. ChromaDB uses cosine similarity on the embeddings.
132
- """
133
- query = f"trend momentum growth decline viral popularity media coverage {topic}"
134
-
135
- results = collection.query(
136
- query_texts=[query],
137
- n_results=min(n_results, collection.count())
138
- )
139
- return results['documents'][0], results['metadatas'][0]
140
-
141
-
142
- # ─── STEP 6: GENERATE ─────────────────────────────────────────────────────────
143
-
144
- def analyse_with_claude(
145
- topic: str,
146
- retrieved_docs: list[str],
147
- retrieved_meta: list[dict],
148
- total_articles: int,
149
- days_back: int,
150
- anthropic_key: str
151
- ) -> dict:
152
- """
153
- Pass the retrieved chunks (NOT all articles) to Claude for analysis.
154
-
155
- This is the core RAG advantage: Claude only sees the most semantically
156
- relevant evidence, which produces more focused and accurate analysis
157
- than dumping all 50 articles into the prompt.
158
- """
159
- # Build context block from retrieved chunks
160
- context = "\n\n".join([
161
- f"[{m['source']} · {m['published_at']}]\n{doc}"
162
- for doc, m in zip(retrieved_docs, retrieved_meta)
163
- ])
164
-
165
- prompt = f"""You are a senior trend analyst at a social listening company (like Pulsar, Brandwatch, or Sprinklr).
166
-
167
- You have been given the {len(retrieved_docs)} most semantically relevant news excerpts about "{topic}",
168
- retrieved via RAG from a corpus of {total_articles} articles published in the last {days_back} days.
169
-
170
- RETRIEVED CONTEXT:
171
- {context}
172
-
173
- Based on this evidence, classify the trend and return ONLY valid JSON (no markdown, no explanation):
174
- {{
175
- "phase": "Early|Rising|Peak|Declining|Fading",
176
- "confidence": <integer 0-100>,
177
- "momentum_score": <integer 0-100>,
178
- "summary": "<2-3 sentence narrative of the trend's current state and why>",
179
- "key_signals": [
180
- "<specific evidence signal from the articles>",
181
- "<specific evidence signal from the articles>",
182
- "<specific evidence signal from the articles>",
183
- "<specific evidence signal from the articles>"
184
- ],
185
- "prediction_30d": "<one concrete sentence: what will likely happen to this trend in 30 days>",
186
- "media_spread": "Niche|Specialist|Mainstream|Viral",
187
- "top_themes": ["<theme 1>", "<theme 2>", "<theme 3>"]
188
- }}
189
-
190
- Phase definitions:
191
- - Early: Emerging signal, limited coverage, mostly specialist/niche sources
192
- - Rising: Volume growing, broadening coverage, sentiment intensifying
193
- - Peak: Maximum velocity, mainstream saturation, brand/celebrity involvement
194
- - Declining: Volume dropping, novelty fading, sentiment normalising
195
- - Fading: Minimal coverage, topic becoming dated, resolved, or replaced"""
196
-
197
- client = anthropic.Anthropic(api_key=anthropic_key)
198
- message = client.messages.create(
199
- model="claude-sonnet-4-20250514",
200
- max_tokens=900,
201
- messages=[{"role": "user", "content": prompt}]
202
- )
203
-
204
- return json.loads(message.content[0].text)
205
-
206
-
207
- # ─── PIPELINE ORCHESTRATOR ────────────────────────────────────────────────────
208
-
209
- def run_analysis(topic: str, days_back: int, newsapi_key_input: str, anthropic_key_input: str):
210
- """
211
- Runs the full 6-step RAG pipeline and returns formatted Gradio outputs.
212
- Returns 6 values matching the 6 gr.Markdown output components.
213
- """
214
- EMPTY = ("", "", "", "", "", "")
215
-
216
- if not topic.strip():
217
- return ("⚠️ Please enter a topic.", *EMPTY[1:])
218
-
219
- # Resolve API keys: UI input → environment variable → error
220
- news_key = newsapi_key_input.strip() or os.environ.get("NEWSAPI_KEY", "")
221
- claude_key = anthropic_key_input.strip() or os.environ.get("ANTHROPIC_API_KEY", "")
222
-
223
- if not news_key:
224
- return ("⚠️ NewsAPI key is required. Get a free key at newsapi.org", *EMPTY[1:])
225
- if not claude_key:
226
- return ("⚠️ Anthropic API key is required.", *EMPTY[1:])
227
-
228
- try:
229
- # ── Step 1: Fetch ──────────────────────────────────────────────────
230
- articles = fetch_articles(topic, int(days_back), news_key)
231
- if not articles:
232
- return (
233
- f"⚠️ No articles found for **'{topic}'** in the last {days_back} days.\n\n"
234
- "Try a broader topic, shorter name, or longer date range.",
235
- *EMPTY[1:]
236
- )
237
-
238
- # ── Steps 2–4: Chunk → Embed → Store ──────────────────────────────
239
- chunks, metadatas = chunk_articles(articles)
240
- collection = build_vector_store(chunks, metadatas)
241
-
242
- # ── Step 5: Retrieve ─────────────���─────────────────────────────────
243
- retrieved_docs, retrieved_meta = retrieve_context(collection, topic)
244
-
245
- # ── Step 6: Generate ───────────────────────────────────────────────
246
- result = analyse_with_claude(
247
- topic, retrieved_docs, retrieved_meta,
248
- len(articles), int(days_back), claude_key
249
- )
250
-
251
- # ── Format outputs ─────────────────────────────────────────────────
252
- phase_icons = {
253
- "Early": "🌱", "Rising": "📈", "Peak": "🔥",
254
- "Declining": "📉", "Fading": "🌫️"
255
- }
256
- phase_colors = {
257
- "Early": "#22c55e", "Rising": "#3b82f6", "Peak": "#f97316",
258
- "Declining": "#ef4444", "Fading": "#94a3b8"
259
- }
260
- phase = result.get("phase", "Unknown")
261
- icon = phase_icons.get(phase, "❓")
262
- color = phase_colors.get(phase, "#6b7280")
263
-
264
- phase_md = f"## {icon} {phase.upper()}"
265
-
266
- scores_md = f"""### 📊 Metrics
267
- | | |
268
- |---|---|
269
- | **Confidence** | `{result.get('confidence', '—')}%` |
270
- | **Momentum Score** | `{result.get('momentum_score', '—')} / 100` |
271
- | **Media Spread** | `{result.get('media_spread', '—')}` |
272
- | **Articles Fetched** | `{len(articles)}` |
273
- | **Chunks in Vector Store** | `{len(chunks)}` |
274
- | **Chunks Retrieved via RAG** | `{len(retrieved_docs)}` |"""
275
-
276
- summary_md = f"### 📝 Narrative\n{result.get('summary', '')}"
277
-
278
- signals_md = "### 🔍 Key Signals\n" + "\n".join(
279
- [f"- {s}" for s in result.get('key_signals', [])]
280
- ) + "\n\n**Top Themes:** " + " ".join(
281
- [f"`{t}`" for t in result.get('top_themes', [])]
282
- )
283
-
284
- prediction_md = f"### 🔮 30-Day Prediction\n_{result.get('prediction_30d', '')}_"
285
-
286
- sources_md = "### 📰 Articles Used in RAG Retrieval\n" + "\n".join([
287
- f"- **{m['source']}** ({m['published_at']}): _{m['title']}_"
288
- for m in retrieved_meta[:6]
289
- ])
290
-
291
- return phase_md, scores_md, summary_md, signals_md, prediction_md, sources_md
292
-
293
- except json.JSONDecodeError:
294
- return ("⚠️ Could not parse analysis response. Please try again.", *EMPTY[1:])
295
- except Exception as e:
296
- return (f"⚠️ Error: {str(e)}", *EMPTY[1:])
297
-
298
-
299
- # ─── GRADIO UI ────────────────────────────────────────────────────────────────
300
-
301
- CSS = """
302
- #title-block { text-align: center; padding: 8px 0 4px 0; }
303
- #title-block h1 { font-size: 2rem; font-weight: 800; margin-bottom: 2px; }
304
- #title-block p { color: #64748b; font-size: 0.95rem; }
305
- .phase-box { text-align: center; font-size: 1.8rem; font-weight: 800;
306
- padding: 18px; background: #f8fafc; border-radius: 12px;
307
- border: 2px solid #e2e8f0; min-height: 80px; }
308
- .metric-card { background: #f8fafc; border-radius: 10px; padding: 4px 8px;
309
- border-left: 3px solid #3b82f6; }
310
- footer { display: none !important; }
311
- """
312
-
313
- HOWTO = """
314
- **Step 1 — Fetch:** NewsAPI retrieves up to 50 articles matching your topic from the selected date range.
315
-
316
- **Step 2 — Chunk:** LangChain's `RecursiveCharacterTextSplitter` breaks each article into overlapping 400-character chunks (~80 words). Overlap prevents information loss at chunk boundaries.
317
-
318
- **Step 3 — Embed:** Each chunk is encoded into a 384-dimensional dense vector using `all-MiniLM-L6-v2` (Sentence Transformers) — optimised for semantic similarity, runs without a GPU.
319
-
320
- **Step 4 — Store:** All vectors are loaded into a ChromaDB `EphemeralClient` collection (fully in-memory — no disk writes, stateless per request).
321
-
322
- **Step 5 — Retrieve:** A trend-signal query is run against the vector store. ChromaDB uses cosine similarity to return the 10 most semantically relevant chunks from across all articles.
323
-
324
- **Step 6 — Generate:** Claude receives *only* the retrieved chunks as context — not all 50 articles. This is the core RAG advantage: focused, grounded, efficient analysis with a minimal prompt.
325
- """
326
-
327
- with gr.Blocks(theme=gr.themes.Soft(
328
- primary_hue="blue",
329
- secondary_hue="slate",
330
- font=[gr.themes.GoogleFont("DM Sans"), "ui-sans-serif", "sans-serif"]
331
- ), css=CSS, title="Trend Longevity Analyser") as demo:
332
-
333
- with gr.Column(elem_id="title-block"):
334
- gr.Markdown("""
335
- # 📊 Trend Longevity Analyser
336
- **RAG-powered trend intelligence** · NewsAPI + ChromaDB + Sentence Transformers + Claude
337
- """)
338
-
339
- gr.Markdown("---")
340
-
341
- # ── Inputs ────────────────────────────────────────────────────────────────
342
- with gr.Accordion("🔑 API Keys", open=False):
343
- gr.Markdown(
344
- "_Keys entered here are used only for your request and never stored. "
345
- "If you're hosting this Space privately, set `NEWSAPI_KEY` and "
346
- "`ANTHROPIC_API_KEY` as Secrets instead._"
347
- )
348
- with gr.Row():
349
- newsapi_input = gr.Textbox(label="NewsAPI Key", placeholder="Get free key at newsapi.org", type="password")
350
- anthropic_input = gr.Textbox(label="Anthropic API Key", placeholder="sk-ant-...", type="password")
351
-
352
- with gr.Row():
353
- with gr.Column(scale=4):
354
- topic_input = gr.Textbox(
355
- label="Topic / Trend",
356
- placeholder="e.g. 'Gen Z workplace burnout' · 'green hydrogen' · 'AI companions'",
357
- lines=1
358
- )
359
- with gr.Column(scale=1):
360
- days_input = gr.Slider(label="Days Back", minimum=7, maximum=30, value=30, step=7,
361
- info="Free NewsAPI tier = 30 days max")
362
-
363
- analyse_btn = gr.Button("🔍 Analyse Trend", variant="primary", size="lg")
364
-
365
- gr.Markdown("---")
366
-
367
- # ── Outputs ───────────────────────────────────────────────────────────────
368
- phase_output = gr.Markdown(elem_classes=["phase-box"])
369
-
370
- with gr.Row():
371
- with gr.Column():
372
- scores_output = gr.Markdown(elem_classes=["metric-card"])
373
- signals_output = gr.Markdown()
374
- with gr.Column():
375
- summary_output = gr.Markdown()
376
- prediction_output = gr.Markdown()
377
-
378
- sources_output = gr.Markdown()
379
-
380
- with gr.Accordion("🧠 How the RAG Pipeline Works", open=False):
381
- gr.Markdown(HOWTO)
382
-
383
- gr.Markdown(
384
- "_Built by [Sammie Wong](https://linkedin.com/in/) · "
385
- "Source: [GitHub](https://github.com/) · "
386
- "Powered by [NewsAPI](https://newsapi.org) + [ChromaDB](https://www.trychroma.com) + "
387
- "[Claude](https://anthropic.com)_",
388
- )
389
-
390
- # ── Wire up ───────────────────────────────────────────────────────────────
391
- analyse_btn.click(
392
- fn=run_analysis,
393
- inputs=[topic_input, days_input, newsapi_input, anthropic_input],
394
- outputs=[phase_output, scores_output, summary_output,
395
- signals_output, prediction_output, sources_output]
396
- )
397
-
398
- if __name__ == "__main__":
399
- demo.launch()