AI-Powered Multi-Perspective News App
Build a news bias analyzer with Python, Gemini AI, and Next.js.
Introduction
30 Second Summary
Every news outlet covers the same story differently. A single event can sound alarming from one source and routine from another, making it difficult to spot where editorial bias is shaping your understanding.
In this project, you will build NewsLens, a full-stack application that fetches news articles from multiple outlets and uses AI to cluster them by story, score editorial bias, and generate neutral summaries.
What You'll Build
A Python AI pipeline connected to a Next.js frontend that displays multi-perspective news coverage with color-coded bias indicators.
By the end of this project, you'll have:
- A Python pipeline that fetches articles from multiple outlets via NewsAPI and sends them to Google Gemini for story clustering and bias analysis.
- A Next.js API route that serves AI-analyzed article clusters as JSON to the frontend.
- A multi-perspective comparison UI with side-by-side article cards showing tone, framing, and political lean scores styled with Tailwind CSS.
- Secret Mission: Add a live topic search bar that triggers on-demand AI analysis from the browser.
Are there any prerequisites?
You should have Node.js 20.9+ and Python 3.9+ installed on your machine. Basic familiarity with React and Python will help, but every step is explained in detail.
Set Up the Project
NewsLens has two parts: a Next.js frontend that displays the analyzed stories, and a Python pipeline that fetches news and runs AI analysis.
Before building any features, you need both environments set up and connected to the APIs that power the pipeline. This step gets the scaffolding in place so you can focus on building in the steps ahead.
In this step, get ready to:
- Scaffold the Next.js frontend project.
- Set up the Python pipeline environment with dependencies.
- Obtain API keys and configure environment variables.
Scaffold the Next.js project
Next.js provides a scaffolding tool called create-next-app that sets up a project with TypeScript, Tailwind CSS, and the App Router pre-configured.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the Next.js project by running this command:
npx create-next-app@latest newslens --yes
What does this command do?
The --yes flag tells create-next-app to use the recommended defaults: TypeScript, ESLint, Tailwind CSS, and the App Router. This skips the interactive setup prompts so you get a ready-to-go project in one command.
If npx asks you to confirm installing the create-next-app package, enter y to proceed.
- Move into the new project folder by running:
cd newslens
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
- You should see the default Next.js welcome page with the Vercel logo and links to documentation.
Don't see the welcome page?
- Make sure you are inside the newslens folder before running npm run dev. Run pwd to check your current directory.
- If port 3000 is already in use, the terminal will show a different port number. Check the URL printed in the terminal output.
- Help me debug my Next.js dev server
- Stop the development server by pressing Ctrl+C in your terminal.
Set up the Python pipeline environment
The AI analysis pipeline lives in its own directory inside the project. A virtual environment keeps the Python dependencies isolated from your system.
- Create the pipeline directory and set up a virtual environment by running these commands:
mkdir pipeline
python3 -m venv pipeline/venv
- Activate the virtual environment by running:
macOS
source pipeline/venv/bin/activate
Windows
pipeline\venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation that keeps this project's packages separate from your system Python. Anything you install while the venv is active only lives inside the pipeline/venv/ folder.
- Create a new file called pipeline/requirements.txt and add the following content:
google-genai==2.10.0
requests==2.34.2
pydantic==2.11.7
python-dotenv==1.1.0
What are these packages?
- google-genai is the official SDK for calling Google's Gemini AI models.
- requests handles HTTP calls to the NewsAPI endpoint.
- pydantic defines typed data models and validates the JSON that Gemini returns.
- python-dotenv loads environment variables from .env files so you can keep secrets out of your code.
- Install all four packages by running:
pip install -r pipeline/requirements.txt
- Confirm the packages installed by running:
pip list
You should see google-genai, requests, pydantic, and python-dotenv listed in the output with their version numbers.
Seeing a permission error or packages not installing?
- Check that (venv) appears in your terminal prompt. If it does not, re-run the activation command from the platform tab above.
- If you see a network error, check your internet connection and try the install command again.
- Help me fix my Python pip install
✔️ Awesome, I've got everything!
Great. Double check you have saved pipeline/requirements.txt.
ⓧ I'd like to double check the full code
google-genai==2.10.0
requests==2.34.2
pydantic==2.11.7
python-dotenv==1.1.0
Create API keys and configure environment variables
The pipeline needs two API keys: one for fetching news articles and one for AI analysis. Both services offer free tiers that cover everything in this project.
- Open https://newsapi.org/register in your browser.
- Enter your first name, email address, and a password.
- Select I am an individual.
- Agree to the terms and click Submit.
- Copy the API key shown on your account page. Save it somewhere temporarily.
What is NewsAPI?
NewsAPI is a REST API that searches articles from over 150,000 worldwide news sources. The free developer tier gives you 100 requests per day, which is more than enough for development and testing.
- Open https://ai.google.dev in your browser.
- Sign in with your Google account if prompted.
- Click Get API key.
- Click Create API key and copy the generated key. Save it somewhere temporarily.
What is Google AI Studio?
Google AI Studio is the developer portal for accessing Gemini models. The free tier provides 60 requests per minute and 1 million tokens per day with no credit card required.
Now store both keys in environment variable files so your code can access them without hardcoding secrets.
- Create a file called .env inside the pipeline/ directory with the following content:
NEWSAPI_KEY=your-newsapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here
- Replace your-newsapi-key-here with the NewsAPI key you copied.
- Replace your-gemini-api-key-here with the Google AI Studio key you copied.
- Create a second file called .env.local in the newslens/ project root (the same folder as package.json) with the same keys:
NEWSAPI_KEY=your-newsapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here
- Replace the placeholder values with your actual API keys, just like you did for pipeline/.env.
Why two .env files?
The Python pipeline reads from pipeline/.env using the python-dotenv package. The Next.js app reads from .env.local automatically through its built-in environment variable handling.
Keeping them separate means each part of the app loads only its own config.
Keep your keys safe
Never commit .env or .env.local files to Git. The .gitignore that create-next-app generated already excludes .env.local. Add pipeline/.env to .gitignore if you push this project to a repository.
- Save both files.
✔️ Awesome, I've got everything!
Great. Make sure both pipeline/.env and .env.local are saved with your real API keys.
ⓧ I'd like to double check the full code
NEWSAPI_KEY=your-newsapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here
NEWSAPI_KEY=your-newsapi-key-here
GEMINI_API_KEY=your-gemini-api-key-here
Your environment is ready. Next up, you will build the Python news fetcher that pulls articles from multiple outlets.
Build the Python News Fetcher
Your development environment is fully set up with API keys and Python packages ready to go. Now it's time to build the first core piece of NewsLens: a script that fetches news articles from multiple outlets.
NewsAPI gives you access to over 150,000 news sources worldwide. The challenge is pulling articles from enough different outlets to make bias comparison meaningful. In this step, you'll write a Python fetcher that queries NewsAPI and confirms you're getting multi-source coverage.
In this step, get ready to:
- Create a Python module that connects to the NewsAPI Everything endpoint.
- Parse multi-source article data and track outlet diversity.
- Test the fetcher to confirm articles come from 3+ distinct sources.
Create the news fetcher module
The NewsAPI Everything endpoint lets you search articles by keyword across thousands of sources. Your fetcher will call this endpoint, request English articles sorted by publication date, and return structured data for each result.
- In VS Code, create a new file called fetch_news.py inside the pipeline/ folder.
- Add the following code to set up the imports, load your API key, and define the start of the fetcher function:
import os
import requests
from dotenv import load_dotenv
load_dotenv()
NEWSAPI_KEY = os.getenv("NEWSAPI_KEY")
NEWSAPI_URL = "https://newsapi.org/v2/everything"
def fetch_articles(topic: str, page_size: int = 20) -> list[dict]:
"""Fetch articles from NewsAPI for a given topic."""
params = {
"q": topic,
"language": "en",
"sortBy": "publishedAt",
"pageSize": page_size,
"apiKey": NEWSAPI_KEY,
}
response = requests.get(NEWSAPI_URL, params=params)
response.raise_for_status()
data = response.json()
if data["status"] != "ok":
raise Exception(f"NewsAPI error: {data.get('message', 'Unknown error')}")
What does this code do?
load_dotenv() reads the pipeline/.env file so your API key is available as an environment variable.
The params dictionary maps directly to NewsAPI's query parameters: q is the search topic, sortBy returns newest articles first, and pageSize caps results at 20. The raise_for_status() call throws an error if the HTTP response indicates a problem (like a 401 for bad credentials).
You won't see any output yet. The parsing logic in the next section completes the function.
Parse the response and track source diversity
NewsAPI returns a JSON object with an articles array. Each article contains nested fields like source.name, title, description, and publishedAt. You'll extract these into a clean list of dictionaries and use a set to track how many unique outlets appear.
- Still inside the fetch_articles function in fetch_news.py, add this code directly below the status check:
articles = []
seen_sources = set()
for article in data.get("articles", []):
source_name = article.get("source", {}).get("name", "Unknown")
articles.append({
"title": article.get("title", ""),
"description": article.get("description", ""),
"source": source_name,
"url": article.get("url", ""),
"publishedAt": article.get("publishedAt", ""),
})
seen_sources.add(source_name)
print(f"Fetched {len(articles)} articles from {len(seen_sources)} sources")
return articles
What does this code do?
The seen_sources set collects every unique outlet name as you iterate. Because it's a set, duplicates are automatically ignored.
Each article gets flattened into a simple dictionary with just the five fields you need: title, description, source, url, and publishedAt. The print statement at the end gives you a quick diversity check every time the function runs.
The function is complete but has no entry point yet. The next section adds a test runner so you can verify everything works.
Test the fetcher independently
A __main__ guard lets you run the file directly as a script. This way you can test the fetcher without importing it into another module.
- At the bottom of fetch_news.py (outside the function, with no indentation), add this test runner:
if __name__ == "__main__":
import sys
topic = sys.argv[1] if len(sys.argv) > 1 else "artificial intelligence"
results = fetch_articles(topic)
for a in results[:5]:
print(f"[{a['source']}] {a['title']}")
What does this code do?
If you pass a topic as a command-line argument, the script uses it. Otherwise it defaults to "artificial intelligence" so you always get results.
The loop prints the first 5 articles with their source name in brackets. This makes it easy to visually confirm you're getting coverage from multiple outlets.
- Save fetch_news.py.
- Make sure your virtual environment is still active (you should see (venv) in your terminal prompt).
- Run the fetcher by executing this command:
python pipeline/fetch_news.py
- You should see output like Fetched 20 articles from 12 sources followed by 5 article lines showing different outlet names in brackets.
- Confirm you're getting articles from at least 3 distinct sources.
Seeing a 401 error?
A 401 - Unauthorized response means your API key is missing or invalid. Open pipeline/.env and confirm NEWSAPI_KEY contains your actual key with no extra spaces or quotes around the value.
If you see rateLimited in the error, you've hit the free tier's 100 requests/day limit. Wait a few minutes or try again tomorrow.
help me debug my NewsAPI fetch_news.py script
✔️ Awesome, I've got everything!
Great. Double check you've saved fetch_news.py and your output shows articles from multiple sources.
ⓧ I'd like to double check the full code
import os
import requests
from dotenv import load_dotenv
load_dotenv()
NEWSAPI_KEY = os.getenv("NEWSAPI_KEY")
NEWSAPI_URL = "https://newsapi.org/v2/everything"
def fetch_articles(topic: str, page_size: int = 20) -> list[dict]:
"""Fetch articles from NewsAPI for a given topic."""
params = {
"q": topic,
"language": "en",
"sortBy": "publishedAt",
"pageSize": page_size,
"apiKey": NEWSAPI_KEY,
}
response = requests.get(NEWSAPI_URL, params=params)
response.raise_for_status()
data = response.json()
if data["status"] != "ok":
raise Exception(f"NewsAPI error: {data.get('message', 'Unknown error')}")
articles = []
seen_sources = set()
for article in data.get("articles", []):
source_name = article.get("source", {}).get("name", "Unknown")
articles.append({
"title": article.get("title", ""),
"description": article.get("description", ""),
"source": source_name,
"url": article.get("url", ""),
"publishedAt": article.get("publishedAt", ""),
})
seen_sources.add(source_name)
print(f"Fetched {len(articles)} articles from {len(seen_sources)} sources")
return articles
if __name__ == "__main__":
import sys
topic = sys.argv[1] if len(sys.argv) > 1 else "artificial intelligence"
results = fetch_articles(topic)
for a in results[:5]:
print(f"[{a['source']}] {a['title']}")
Your news fetcher is pulling real articles from multiple outlets on any topic you choose. Next up, you'll send these articles to Google Gemini for AI-powered clustering and bias analysis.
Build the AI Analysis Pipeline
Your fetcher is pulling fresh articles from multiple news outlets. That raw data is useful, but it doesn't tell you which articles cover the same story or how each outlet's coverage differs.
In this step, you'll build the AI brain of NewsLens. A set of Pydantic models will define the exact shape of your analysis output. Then Google Gemini will cluster articles by story, score each article's editorial bias, and return structured JSON that your frontend can consume.
In this step, get ready to:
- Define Pydantic models for structured AI output.
- Build the Gemini analysis function with bias scoring.
- Create an orchestrator script that runs the full pipeline end to end.
Define the Pydantic schema models
Before calling the AI, you need to define exactly what shape the response should take. Pydantic models act as both documentation and validation. Gemini will fill in the fields, and Pydantic will reject any response that doesn't match.
- Create a new file called analyze.py inside your pipeline/ directory.
- Add the following imports, config, and model definitions:
import os
import json
from google import genai
from pydantic import BaseModel, Field
from typing import List
from dotenv import load_dotenv
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
class BiasScore(BaseModel):
tone: int = Field(description="Tone score 1-5: 1=very negative, 3=neutral, 5=very positive")
framing: int = Field(description="Framing score 1-5: 1=highly sensational, 3=balanced, 5=highly analytical")
political_lean: str = Field(description="Political lean: Left, Center-Left, Center, Center-Right, or Right")
class AnalyzedArticle(BaseModel):
title: str = Field(description="The article headline")
source: str = Field(description="The news outlet name")
url: str = Field(description="Link to the original article")
published_at: str = Field(description="Publication date ISO format")
bias: BiasScore = Field(description="Multi-dimensional bias assessment")
class StoryCluster(BaseModel):
cluster_title: str = Field(description="A neutral title describing the event or story")
summary: str = Field(description="A neutral 2-3 sentence summary covering who, what, when, where, why")
articles: List[AnalyzedArticle] = Field(description="Articles covering this story from different outlets")
class AnalysisResult(BaseModel):
clusters: List[StoryCluster] = Field(description="Groups of articles clustered by story")
What do these models do?
Each class defines one layer of your analysis output. BiasScore holds three dimensions of editorial bias: tone (negative to positive), framing (sensational to analytical), and political lean.
AnalyzedArticle wraps a single article's metadata with its bias score. StoryCluster groups related articles together under a neutral summary. AnalysisResult is the top-level container that Gemini fills in.
The Field(description=...) on each attribute tells Gemini what to put there. When you pass the schema to Gemini, these descriptions guide the model's output.
- Save analyze.py.
You won't see a visible change yet. The next chunk adds the function that uses these models to call Gemini.
Build the Gemini analysis function
With the schema defined, you can now write the function that sends articles to Gemini and gets back structured bias analysis. The google-genai SDK's structured output feature forces Gemini to return JSON matching your Pydantic schema exactly.
- In pipeline/analyze.py, add the following function below the AnalysisResult class:
def analyze_articles(articles: list[dict]) -> dict:
"""Send articles to Gemini for clustering and bias analysis."""
client = genai.Client(api_key=GEMINI_API_KEY)
articles_text = json.dumps(articles, indent=2)
prompt = f"""You are a media bias analyst. Given the following news articles, perform these tasks:
1. CLUSTER: Group articles that cover the same story/event together. Create 2-4 clusters.
2. SUMMARIZE: Write a neutral 2-3 sentence summary for each cluster.
3. SCORE BIAS: For each article, assess:
- tone (1-5): 1=very negative, 3=neutral, 5=very positive
- framing (1-5): 1=highly sensational, 3=balanced, 5=highly analytical
- political_lean: Left, Center-Left, Center, Center-Right, or Right
Articles to analyze:
{articles_text}
Return the analysis as structured JSON matching the provided schema."""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
config={
"response_format": {
"text": {
"mime_type": "application/json",
"schema": AnalysisResult.model_json_schema(),
}
}
},
)
result = AnalysisResult.model_validate_json(response.text)
return result.model_dump()
What does this function do?
- genai.Client(api_key=GEMINI_API_KEY) creates a Gemini API client using the key from your .env file.
- The prompt instructs Gemini to cluster articles, summarize each cluster neutrally, and score bias on three dimensions.
- The config with response_format passes your Pydantic schema to Gemini so it returns JSON matching the exact structure you defined.
- AnalysisResult.model_validate_json(response.text) parses the JSON response and validates every field matches your schema. If Gemini returns malformed data, this raises an error immediately.
- result.model_dump() converts the validated Pydantic object into a plain Python dictionary for easy serialization.
Why structured output instead of free-text?
Without structured output, you would need to parse Gemini's freeform text response with regex or string splitting. Structured output guarantees the response is valid JSON matching your schema. If a field is missing or the wrong type, Pydantic catches it before your frontend ever sees bad data.
Now add a test block at the bottom of the file so you can verify the function works independently.
- Add this test code at the very bottom of pipeline/analyze.py:
if __name__ == "__main__":
sample = [
{"title": "AI Breakthrough in Healthcare", "description": "New AI model detects cancer early", "source": "TechCrunch", "url": "https://example.com/1", "publishedAt": "2026-07-07T10:00:00Z"},
{"title": "Concerns Rise Over AI in Medicine", "description": "Critics warn of AI bias in diagnosis", "source": "The Guardian", "url": "https://example.com/2", "publishedAt": "2026-07-07T11:00:00Z"},
]
result = analyze_articles(sample)
print(json.dumps(result, indent=2))
What does this test block do?
The if __name__ == "__main__" block only runs when you execute the file directly. It sends two sample articles (one positive, one critical) to Gemini and prints the structured analysis result. This lets you verify the AI integration works before wiring it into the full pipeline.
- Save analyze.py.
- Make sure your virtual environment is still active (you should see (venv) in your terminal prompt), then test the analyzer by running:
python pipeline/analyze.py
After 5-10 seconds, you should see structured JSON output with a clusters array containing story groupings, summaries, and bias scores for each article.
Seeing an API error?
- Check that your GEMINI_API_KEY in pipeline/.env is correct. Open pipeline/.env and confirm the key has no extra spaces or quotes around it.
- If you see a timeout, try running the command again. The free tier has rate limits of 60 requests per minute.
- Make sure you activated your virtual environment with source pipeline/venv/bin/activate (macOS) or pipeline\venv\Scripts\activate (Windows).
Help me debug my Gemini API call in analyze.py
✔️ Awesome, I've got everything!
Great. Double check you've saved analyze.py and that your test produced valid JSON output.
ⓧ I'd like to double check the full code
import os
import json
from google import genai
from pydantic import BaseModel, Field
from typing import List
from dotenv import load_dotenv
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
class BiasScore(BaseModel):
tone: int = Field(description="Tone score 1-5: 1=very negative, 3=neutral, 5=very positive")
framing: int = Field(description="Framing score 1-5: 1=highly sensational, 3=balanced, 5=highly analytical")
political_lean: str = Field(description="Political lean: Left, Center-Left, Center, Center-Right, or Right")
class AnalyzedArticle(BaseModel):
title: str = Field(description="The article headline")
source: str = Field(description="The news outlet name")
url: str = Field(description="Link to the original article")
published_at: str = Field(description="Publication date ISO format")
bias: BiasScore = Field(description="Multi-dimensional bias assessment")
class StoryCluster(BaseModel):
cluster_title: str = Field(description="A neutral title describing the event or story")
summary: str = Field(description="A neutral 2-3 sentence summary covering who, what, when, where, why")
articles: List[AnalyzedArticle] = Field(description="Articles covering this story from different outlets")
class AnalysisResult(BaseModel):
clusters: List[StoryCluster] = Field(description="Groups of articles clustered by story")
def analyze_articles(articles: list[dict]) -> dict:
"""Send articles to Gemini for clustering and bias analysis."""
client = genai.Client(api_key=GEMINI_API_KEY)
articles_text = json.dumps(articles, indent=2)
prompt = f"""You are a media bias analyst. Given the following news articles, perform these tasks:
1. CLUSTER: Group articles that cover the same story/event together. Create 2-4 clusters.
2. SUMMARIZE: Write a neutral 2-3 sentence summary for each cluster.
3. SCORE BIAS: For each article, assess:
- tone (1-5): 1=very negative, 3=neutral, 5=very positive
- framing (1-5): 1=highly sensational, 3=balanced, 5=highly analytical
- political_lean: Left, Center-Left, Center, Center-Right, or Right
Articles to analyze:
{articles_text}
Return the analysis as structured JSON matching the provided schema."""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
config={
"response_format": {
"text": {
"mime_type": "application/json",
"schema": AnalysisResult.model_json_schema(),
}
}
},
)
result = AnalysisResult.model_validate_json(response.text)
return result.model_dump()
if __name__ == "__main__":
sample = [
{"title": "AI Breakthrough in Healthcare", "description": "New AI model detects cancer early", "source": "TechCrunch", "url": "https://example.com/1", "publishedAt": "2026-07-07T10:00:00Z"},
{"title": "Concerns Rise Over AI in Medicine", "description": "Critics warn of AI bias in diagnosis", "source": "The Guardian", "url": "https://example.com/2", "publishedAt": "2026-07-07T11:00:00Z"},
]
result = analyze_articles(sample)
print(json.dumps(result, indent=2))
Create the pipeline orchestrator
You now have two independent scripts: a fetcher and an analyzer. The orchestrator ties them together into a single command that fetches live articles, analyzes them with AI, and writes the results to a JSON file your Next.js app can serve.
- Create a new file called run.py inside your pipeline/ directory.
- Add the following orchestrator code:
import sys
import json
import os
from fetch_news import fetch_articles
from analyze import analyze_articles
def main():
topic = sys.argv[1] if len(sys.argv) > 1 else "artificial intelligence"
print(f"Running NewsLens pipeline for topic: {topic}")
print("Step 1: Fetching articles...")
articles = fetch_articles(topic)
if not articles:
print("No articles found. Exiting.")
sys.exit(1)
print("Step 2: Analyzing with AI...")
result = analyze_articles(articles)
output_dir = os.path.join(os.path.dirname(__file__), "..", "public", "data")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "clusters.json")
with open(output_path, "w") as f:
json.dump(result, f, indent=2)
print(f"Done! Output written to {output_path}")
print(f"Found {len(result['clusters'])} story clusters")
if __name__ == "__main__":
main()
What does the orchestrator do?
- It accepts an optional topic argument from the command line, defaulting to "artificial intelligence".
- It calls fetch_articles(topic) to pull live articles from NewsAPI.
- It passes those articles to analyze_articles(articles) for AI clustering and bias scoring.
- It writes the final JSON to public/data/clusters.json inside your Next.js project root. This is the file your API route will serve to the frontend later.
- Save run.py.
- Run the full pipeline by executing this command from your project root:
cd pipeline && python run.py
The pipeline takes 10-20 seconds. You should see output showing it fetched articles, analyzed them, and wrote the result to the public/data/clusters.json file.
- Confirm the output file was created by running:
ls ../public/data/
You should see clusters.json listed in the output.
Pipeline failing or no output file?
- If you see an import error for fetch_news or analyze, make sure you ran the command from inside the pipeline/ directory. The imports use relative paths.
- If the NewsAPI call fails with a 401 error, double check your NEWSAPI_KEY in pipeline/.env.
- If the script says "No articles found", try a broader topic like "technology" or "politics".
Help me debug my pipeline orchestrator run.py
✔️ Awesome, I've got everything!
Great. Double check you've saved run.py and that clusters.json exists in your public/data/ folder.
ⓧ I'd like to double check the full code
import sys
import json
import os
from fetch_news import fetch_articles
from analyze import analyze_articles
def main():
topic = sys.argv[1] if len(sys.argv) > 1 else "artificial intelligence"
print(f"Running NewsLens pipeline for topic: {topic}")
print("Step 1: Fetching articles...")
articles = fetch_articles(topic)
if not articles:
print("No articles found. Exiting.")
sys.exit(1)
print("Step 2: Analyzing with AI...")
result = analyze_articles(articles)
output_dir = os.path.join(os.path.dirname(__file__), "..", "public", "data")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "clusters.json")
with open(output_path, "w") as f:
json.dump(result, f, indent=2)
print(f"Done! Output written to {output_path}")
print(f"Found {len(result['clusters'])} story clusters")
if __name__ == "__main__":
main()
Your AI pipeline is complete. It fetches live news, clusters stories, and scores bias. Next up, you'll wire this data into your Next.js app so it can serve the analysis to your frontend.
Connect the Pipeline to Next.js
Your Python pipeline produces a clusters.json file full of analyzed, bias-scored articles. That data is sitting on disk, but your Next.js frontend has no way to access it yet.
In this step, you will build the bridge between the Python output and the browser. A Next.js API route reads the JSON file from disk and serves it over HTTP, so the frontend can fetch it like any other API.
In this step, get ready to:
- Create a Next.js API route that serves your pipeline output as JSON.
- Define TypeScript interfaces that mirror your Python Pydantic models.
- Test the full data flow from Python pipeline to browser.
Create the API route
Next.js uses a file-based routing system. A file named route.ts inside the app/ directory becomes a server-side endpoint. The folder path becomes the URL path.
- In your VS Code file explorer, create a new folder path: app/api/stories/.
- Inside the stories/ folder, create a new file called route.ts.
- Paste the following code into app/api/stories/route.ts:
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET() {
try {
// Build the path to the pipeline's output file
const filePath = path.join(process.cwd(), "public", "data", "clusters.json");
// Read the JSON file from disk
const fileContents = fs.readFileSync(filePath, "utf-8");
// Parse and return as a JSON response
const data = JSON.parse(fileContents);
return NextResponse.json(data);
} catch {
// Return a helpful error if the pipeline hasn't been run yet
return NextResponse.json(
{ error: "No analysis data found. Run the pipeline first." },
{ status: 404 }
);
}
}
What does this code do?
This file exports a GET function, which means Next.js will call it whenever someone visits /api/stories in their browser or fetches that URL from JavaScript.
- path.join(process.cwd(), "public", "data", "clusters.json") builds the absolute path to the file your Python pipeline wrote.
- fs.readFileSync reads the file contents synchronously on the server.
- NextResponse.json(data) returns the parsed data as a properly formatted JSON HTTP response.
- The catch block handles the case where clusters.json does not exist yet, returning a 404 with a helpful message.
- Save app/api/stories/route.ts.
Why a Route Handler instead of serving the file directly?
Files in public/ are technically accessible directly via URL. However, wrapping the data in an API route gives you a single endpoint the frontend can fetch() from, and lets you add error handling, filtering, or authentication later without changing the frontend code.
Seeing a module error?
If you see an error about fs or path not being found, make sure route.ts is inside app/api/stories/ and not inside a client component. Route Handlers run on the server where Node.js APIs are available.
help me fix a module not found error in my Next.js API route
Define TypeScript interfaces for the frontend
Your Python pipeline uses Pydantic models to define the shape of its output. The Next.js frontend needs equivalent TypeScript interfaces so you get type safety and autocompletion when working with the data.
Here are the interfaces you will use in the next step when building the UI. They mirror the Python models exactly:
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
interface StoryCluster {
cluster_title: string;
summary: string;
articles: AnalyzedArticle[];
}
interface AnalysisResult {
clusters: StoryCluster[];
}
How do the Python and TypeScript models map?
- BiasScore in Python has tone: int, framing: int, political_lean: str. In TypeScript, these become number and string.
- AnalyzedArticle keeps the same field names including published_at with the underscore, matching the JSON output.
- StoryCluster contains a cluster_title, a summary, and an articles array of AnalyzedArticle objects.
- AnalysisResult is the top-level wrapper with a clusters array, matching the JSON root your pipeline writes.
You will add these interfaces directly into app/page.tsx in the next step. For now, understanding the mapping between your Python output and TypeScript types is the key takeaway.
Test the full data flow
Your pipeline already generated clusters.json in the previous step. Now you can verify the API route serves it correctly.
- Make sure your Next.js dev server is still running. If it stopped, start it again by running:
- Open your browser and visit http://localhost:3000/api/stories.
npm run dev
You should see a large JSON response in your browser containing your story clusters, each with a cluster_title, summary, and articles array with bias scores.
What should I see?
The browser will display raw JSON data. Look for the clusters array at the top level. Each cluster should have articles with bias.tone, bias.framing, and bias.political_lean fields. This confirms the full pipeline-to-API data flow is working.
Seeing a 404 error message?
If you see {"error":"No analysis data found. Run the pipeline first."}, your public/data/clusters.json file is missing. Switch back to your terminal with the Python virtual environment active and re-run the pipeline:
cd pipeline
python run.py
Then refresh your browser.
help me debug why my API route returns a 404 even though I ran the pipeline
✔️ Awesome, I've got everything!
Great. Double check you have saved app/api/stories/route.ts and confirmed JSON appears in your browser.
ⓧ I'd like to double check the full code
Here is the complete app/api/stories/route.ts file:
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET() {
try {
const filePath = path.join(process.cwd(), "public", "data", "clusters.json");
const fileContents = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(fileContents);
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: "No analysis data found. Run the pipeline first." },
{ status: 404 }
);
}
}
Your pipeline data is now accessible over HTTP. Next up, you will build the React UI that fetches this data and renders a multi-perspective news comparison.
Build the Multi-Perspective UI
Your Python pipeline produces analyzed data, and your API route serves it. Now you need a frontend that makes the analysis visible and meaningful.
In this step, you will build React components that display story clusters side by side, each article card showing its bias scores through color-coded pills and progress bars. This is where your AI analysis becomes something a user can actually interpret at a glance.
In this step, get ready to:
- Create an ArticleCard component with color-coded bias indicators and progress bars.
- Create a StoryClusterCard component that groups articles in a horizontally scrollable row.
- Build the main page layout that fetches data and renders the full multi-perspective UI.
Create the ArticleCard component
Each article card needs to communicate three dimensions of bias at a glance: political lean (as a colored pill), tone (as a progress bar), and framing (as a progress bar). You will build this as a reusable component that receives a single article's data as a prop.
- Create a new folder called components in the root of your newslens project (at the same level as app/).
- Create a new file called ArticleCard.tsx inside the components/ folder.
- Add the type interfaces and color configuration by pasting this code:
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
const leanColors: Record<string, string> = {
Left: "bg-blue-600 text-white",
"Center-Left": "bg-blue-400 text-white",
Center: "bg-gray-500 text-white",
"Center-Right": "bg-red-400 text-white",
Right: "bg-red-600 text-white",
};
What does this code do?
The BiasScore and AnalyzedArticle interfaces mirror the Python Pydantic models you defined earlier, ensuring type safety between your pipeline output and your UI.
The leanColors map assigns a Tailwind CSS class string to each political lean value. Blue shades represent left-leaning, gray represents center, and red shades represent right-leaning.
- Below the color configuration in the same file, add the ArticleCard component function:
export default function ArticleCard({ article }: { article: AnalyzedArticle }) {
const leanStyle = leanColors[article.bias.political_lean] || "bg-gray-400 text-white";
const toneWidth = (article.bias.tone / 5) * 100;
const framingWidth = (article.bias.framing / 5) * 100;
return (
<div className="min-w-[280px] max-w-[320px] flex-shrink-0 border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
{article.source}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${leanStyle}`}>
{article.bias.political_lean}
</span>
</div>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="text-sm font-semibold text-gray-900 mb-3 line-clamp-3 hover:text-blue-700">
{article.title}
</h3>
</a>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Tone</span>
<span>{article.bias.tone}/5</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-amber-400 h-1.5 rounded-full"
style={{ width: `${toneWidth}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Framing</span>
<span>{article.bias.framing}/5</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-emerald-400 h-1.5 rounded-full"
style={{ width: `${framingWidth}%` }}
/>
</div>
</div>
</div>
<p className="text-xs text-gray-400 mt-3">
{new Date(article.published_at).toLocaleDateString()}
</p>
</div>
);
}
What does this component render?
- The source name is displayed in uppercase at the top left, with the political lean pill at the top right using the color from leanColors.
- The headline links to the original article in a new tab using target="_blank".
- Tone and framing scores render as progress bars. The width is calculated as (score / 5) * 100 to convert the 1-5 scale into a percentage.
- Save components/ArticleCard.tsx.
You won't see a change in the browser yet. The next component will use ArticleCard to render article rows.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
const leanColors: Record<string, string> = {
Left: "bg-blue-600 text-white",
"Center-Left": "bg-blue-400 text-white",
Center: "bg-gray-500 text-white",
"Center-Right": "bg-red-400 text-white",
Right: "bg-red-600 text-white",
};
export default function ArticleCard({ article }: { article: AnalyzedArticle }) {
const leanStyle = leanColors[article.bias.political_lean] || "bg-gray-400 text-white";
const toneWidth = (article.bias.tone / 5) * 100;
const framingWidth = (article.bias.framing / 5) * 100;
return (
<div className="min-w-[280px] max-w-[320px] flex-shrink-0 border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
{article.source}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${leanStyle}`}>
{article.bias.political_lean}
</span>
</div>
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="text-sm font-semibold text-gray-900 mb-3 line-clamp-3 hover:text-blue-700">
{article.title}
</h3>
</a>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Tone</span>
<span>{article.bias.tone}/5</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-amber-400 h-1.5 rounded-full"
style={{ width: `${toneWidth}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span>Framing</span>
<span>{article.bias.framing}/5</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-emerald-400 h-1.5 rounded-full"
style={{ width: `${framingWidth}%` }}
/>
</div>
</div>
</div>
<p className="text-xs text-gray-400 mt-3">
{new Date(article.published_at).toLocaleDateString()}
</p>
</div>
);
}
Create the StoryClusterCard component
A story cluster groups multiple articles that cover the same event. This component displays the cluster's neutral summary and arranges all related article cards in a horizontally scrollable row so the user can compare coverage side by side.
- Create a new file called StoryClusterCard.tsx inside the components/ folder.
- Add the import, interfaces, and component by pasting this code:
import ArticleCard from "./ArticleCard";
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
interface StoryCluster {
cluster_title: string;
summary: string;
articles: AnalyzedArticle[];
}
export default function StoryClusterCard({ cluster }: { cluster: StoryCluster }) {
return (
<section className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
{cluster.cluster_title}
</h2>
<p className="text-gray-600 text-sm mb-4">{cluster.summary}</p>
<div className="flex overflow-x-auto gap-4 pb-2">
{cluster.articles.map((article, index) => (
<ArticleCard key={index} article={article} />
))}
</div>
</section>
);
}
What does this component do?
- The cluster title renders as an h2 heading, with the AI-generated neutral summary below it.
- The flex overflow-x-auto gap-4 container creates the horizontal scrollable row. When a cluster has more articles than fit on screen, the user can scroll sideways to compare them.
- Each article in the cluster is rendered as an ArticleCard you just created.
- Save components/StoryClusterCard.tsx.
You won't see changes in the browser yet. The main page component needs to be updated to fetch data and render these cluster cards.
Seeing a TypeScript error in your editor?
If your editor shows an error on the import ArticleCard line, make sure ArticleCard.tsx is saved in the same components/ folder. Both files must be siblings.
Help me fix a TypeScript import error in my StoryClusterCard component.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import ArticleCard from "./ArticleCard";
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
interface StoryCluster {
cluster_title: string;
summary: string;
articles: AnalyzedArticle[];
}
export default function StoryClusterCard({ cluster }: { cluster: StoryCluster }) {
return (
<section className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
{cluster.cluster_title}
</h2>
<p className="text-gray-600 text-sm mb-4">{cluster.summary}</p>
<div className="flex overflow-x-auto gap-4 pb-2">
{cluster.articles.map((article, index) => (
<ArticleCard key={index} article={article} />
))}
</div>
</section>
);
}
Build the main page with data fetching and layout
The main page component ties everything together. It fetches the analyzed clusters from your API route on mount, manages loading and error states, and renders the full news-reader layout with your cluster cards.
- Open app/page.tsx in VS Code.
- Replace the entire file contents with the client directive, imports, and type interfaces:
"use client";
import { useEffect, useState } from "react";
import StoryClusterCard from "@/components/StoryClusterCard";
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
interface StoryCluster {
cluster_title: string;
summary: string;
articles: AnalyzedArticle[];
}
interface AnalysisResult {
clusters: StoryCluster[];
}
What does this setup do?
- The "use client" directive marks this as a client component. You need this because the page uses useState and useEffect hooks for data fetching.
- The @/components/StoryClusterCard import uses the Next.js path alias to reference your new component.
- The interfaces match the JSON structure your API returns, giving you type safety when rendering the data.
- Below the interfaces in the same file, add the Home component with data fetching and rendering:
export default function Home() {
const [data, setData] = useState<AnalysisResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/stories")
.then((res) => {
if (!res.ok) throw new Error("No data available");
return res.json();
})
.then((json) => setData(json))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-500 text-lg">Loading stories...</p>
</main>
);
}
if (error) {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 text-lg mb-2">{error}</p>
<p className="text-gray-400 text-sm">
Run the pipeline first: cd pipeline && python run.py
</p>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-gray-50 py-8 px-4">
<header className="max-w-6xl mx-auto mb-8">
<h1 className="text-3xl font-bold text-gray-900">NewsLens</h1>
<p className="text-gray-500 mt-1">
Multi-perspective news analysis powered by AI
</p>
</header>
<div className="max-w-6xl mx-auto space-y-8">
{data?.clusters.map((cluster, index) => (
<StoryClusterCard key={index} cluster={cluster} />
))}
</div>
</main>
);
}
What does the Home component do?
- Three state variables track the data (AnalysisResult | null), loading status, and any error message.
- The useEffect runs once on mount, fetching from your /api/stories route. If the response is not OK, it throws an error.
- The component renders one of three states: a loading message, an error message with instructions, or the full news layout with a header and cluster cards.
- The bg-gray-50 background with max-w-6xl container creates a clean, readable news-reader layout.
- Save app/page.tsx.
- Switch to your browser and check http://localhost:3000. You should see the NewsLens header, followed by white story cluster cards stacking vertically. Each cluster shows a title, summary, and a horizontal row of article cards with colored bias pills and progress bars.
Seeing an error or blank page?
- If you see the red error text saying "No data available", your public/data/clusters.json file may be missing. Re-run the pipeline from the previous step.
- If you see a compile error mentioning StoryClusterCard, confirm the file is saved at components/StoryClusterCard.tsx (not inside app/).
- If the page loads but cards have no color, check that your clusters.json data contains political_lean values matching exactly: Left, Center-Left, Center, Center-Right, or Right.
Help me debug my NewsLens UI not rendering correctly.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved.
ⓧ I'd like to double check the full code
"use client";
import { useEffect, useState } from "react";
import StoryClusterCard from "@/components/StoryClusterCard";
interface BiasScore {
tone: number;
framing: number;
political_lean: string;
}
interface AnalyzedArticle {
title: string;
source: string;
url: string;
published_at: string;
bias: BiasScore;
}
interface StoryCluster {
cluster_title: string;
summary: string;
articles: AnalyzedArticle[];
}
interface AnalysisResult {
clusters: StoryCluster[];
}
export default function Home() {
const [data, setData] = useState<AnalysisResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/stories")
.then((res) => {
if (!res.ok) throw new Error("No data available");
return res.json();
})
.then((json) => setData(json))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-500 text-lg">Loading stories...</p>
</main>
);
}
if (error) {
return (
<main className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-500 text-lg mb-2">{error}</p>
<p className="text-gray-400 text-sm">
Run the pipeline first: cd pipeline && python run.py
</p>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-gray-50 py-8 px-4">
<header className="max-w-6xl mx-auto mb-8">
<h1 className="text-3xl font-bold text-gray-900">NewsLens</h1>
<p className="text-gray-500 mt-1">
Multi-perspective news analysis powered by AI
</p>
</header>
<div className="max-w-6xl mx-auto space-y-8">
{data?.clusters.map((cluster, index) => (
<StoryClusterCard key={index} cluster={cluster} />
))}
</div>
</main>
);
}
Your full-stack NewsLens application is now running. You have a Python AI pipeline producing structured analysis, an API route serving that data, and a React frontend that makes multi-perspective bias visible at a glance. Next up, a Secret Mission that lets users search any topic and trigger the pipeline on demand.
Secret mission
Add Live Topic Search
Right now, NewsLens only shows results for the topic you ran in the terminal. What if users could type any topic and get fresh AI analysis on demand, right from the browser? In this secret mission, you'll wire up a search bar that triggers the full Python pipeline from a Next.js API route and streams the results back to the UI.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses only free-tier APIs. There are no ongoing costs or charges to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- NewsAPI free developer API key (rate-limited to 100 requests/day, no charges).
- Google AI Studio API key (free tier, 60 RPM, no charges).
- Local newslens project folder on your Desktop (Next.js app and Python pipeline).
Keep everything running
No action needed. Choose this if you are still actively building or want to extend the project further.
- Both API keys remain active on the free tier with no cost.
- Your newslens folder stays on your Desktop for easy access.
- The Python virtual environment inside pipeline/venv is ready to use whenever you reactivate it.
Pause - I'll come back to this later
No action needed. Both the NewsAPI and Google AI Studio free tiers have no charges. Your project files will stay on your Desktop until you return.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
- Deactivate the Python virtual environment by running:
deactivate
When you come back, reactivate the environment and start the dev server again.
Delete - I don't want to use this again
Remove all project resources and revoke your API keys to start fresh.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal.
- Delete the project folder by running:
cd ~/Desktop
rm -rf newslens
- Delete your Google AI Studio API key:
- Go to aistudio.google.com/apikey.
- Locate the API key you created for this project.
- Click the delete option next to the key and confirm.
- Delete your NewsAPI account (optional):
- The NewsAPI free tier has no cost and no documented key revocation path. If you want to fully remove your account, contact NewsAPI support at their website. Otherwise, leaving it active incurs no charges.
That's a Wrap!
That's a Wrap!
Nice work! You just built a full-stack AI-powered news analysis application from scratch.
NewsLens proves you can design an end-to-end content analysis pipeline and connect it to a modern web frontend.
You've learned how to:
- Build a Python news ingestion pipeline that fetches articles from 150,000+ sources via NewsAPI, then clusters and scores them using Google Gemini AI with structured JSON output.
- Connect a Python AI pipeline to a Next.js frontend via a JSON data flow pattern using API routes and pre-generated data files.
- Create a multi-perspective story comparison UI with color-coded bias indicators for tone, framing, and political lean using Tailwind CSS.
- Secret Mission: Added live topic search that triggers on-demand AI analysis directly from the browser.
Ready to quiz yourself?