lization commited on
Commit
2a615b8
ยท
verified ยท
1 Parent(s): f4bdbea

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +66 -9
  2. app.py +399 -0
  3. requirements.txt +7 -0
README.md CHANGED
@@ -1,14 +1,71 @@
1
  ---
2
- title: TrendAnalysis
3
- emoji: ๐Ÿ“ˆ
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.15.1
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
11
- short_description: 'A RAG-powered tool '
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Trend Longevity Analyser
3
+ emoji: ๐Ÿ“Š
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
+ pinned: true
 
10
  ---
11
 
12
+ # ๐Ÿ“Š Trend Longevity Analyser
13
+
14
+ A **RAG-powered trend intelligence tool** that classifies whether any topic is *Early, Rising, at Peak, Declining, or Fading* โ€” using live news data.
15
+
16
+ ## How It Works
17
+
18
+ This tool implements a full 6-step RAG (Retrieval-Augmented Generation) pipeline:
19
+
20
+ | Step | What Happens |
21
+ |------|-------------|
22
+ | **1. Fetch** | NewsAPI pulls up to 50 recent articles on your topic |
23
+ | **2. Chunk** | LangChain splits articles into overlapping 400-character chunks |
24
+ | **3. Embed** | `all-MiniLM-L6-v2` encodes each chunk into a 384-dim vector |
25
+ | **4. Store** | ChromaDB holds all vectors in an in-memory collection |
26
+ | **5. Retrieve** | Semantic search returns the 10 most relevant chunks |
27
+ | **6. Generate** | Claude analyses the retrieved context and returns a structured trend signal |
28
+
29
+ ## Tech Stack
30
+
31
+ - **Retrieval:** [ChromaDB](https://www.trychroma.com) (vector store) + [Sentence Transformers](https://sbert.net) (embeddings)
32
+ - **Orchestration:** [LangChain](https://langchain.com) text splitting
33
+ - **News Data:** [NewsAPI](https://newsapi.org) (free tier)
34
+ - **Generation:** [Anthropic Claude](https://anthropic.com)
35
+ - **UI:** [Gradio](https://gradio.app)
36
+
37
+ ## Setup
38
+
39
+ ### Running Locally
40
+
41
+ ```bash
42
+ git clone https://huggingface.co/spaces/lization/trend-longevity-analyser
43
+ cd trend-longevity-analyser
44
+ pip install -r requirements.txt
45
+ export NEWSAPI_KEY=your_key_here
46
+ export ANTHROPIC_API_KEY=your_key_here
47
+ python app.py
48
+ ```
49
+
50
+ ### HF Space Secrets
51
+
52
+ Set `NEWSAPI_KEY` and `ANTHROPIC_API_KEY` as Space Secrets under **Settings โ†’ Variables and Secrets**. If not set, users can enter keys directly in the UI.
53
+
54
+ ## Get Your Free API Keys
55
+
56
+ - **NewsAPI:** [newsapi.org/register](https://newsapi.org/register) โ€” free tier, 100 requests/day, last 30 days of articles
57
+ - **Anthropic:** [console.anthropic.com](https://console.anthropic.com) โ€” Claude API
58
+
59
+ ## Why RAG (Not Just a Prompt)?
60
+
61
+ Instead of passing all 50 articles to Claude (expensive, noisy, hits context limits), the RAG approach:
62
+
63
+ 1. Embeds every chunk as a vector
64
+ 2. Retrieves only the **10 most semantically relevant chunks** via cosine similarity
65
+ 3. Passes those to Claude for analysis
66
+
67
+ This gives more focused, accurate, and cost-efficient results โ€” and is directly analogous to production social listening systems used in enterprise contexts.
68
+
69
+ ---
70
+
71
+ Built by [Sammie Wong](https://linkedin.com/in/sammie-wong/)
app.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.40.0
2
+ anthropic>=0.25.0
3
+ newsapi-python>=0.2.7
4
+ langchain>=0.2.0
5
+ langchain-text-splitters>=0.2.0
6
+ chromadb>=0.5.0
7
+ sentence-transformers>=3.0.0