How a story is told matters as much as what happened.
The same military strike. The same diplomatic summit. The same sanction. Reported by a dozen outlets — each with its own framing, its own agenda, its own version of the truth.
Parallax doesn't tell you what happened. It tells you how every observer chose to frame what happened — automatically, at scale, with zero cloud dependency.
One POST /pipeline call. Scrape → Deduplicate → Analyze → Report.
Real output — Sudan war coverage from Al Jazeera English. Three articles, three different detected stances (anti_humanitarian, anti_conflict), emotional intensity ranging from 70% to 90%. Note: even within a single outlet, Parallax detects distinct narrative framings per article — it analyzes content, not just source.
Table of Contents
In optics, parallax is the apparent displacement of an object when viewed from two different positions. The object hasn't moved — only the observer's angle changed.
News works the same way. Parallax reveals those shifts. It doesn't consume news — it produces intelligence from news.
Everything runs 100% locally. No cloud APIs, no data leaving your machine. Ollama runs LLM inference on your own hardware — built for researchers who treat operational privacy as a hard requirement, not a nice-to-have.
| Capability | Description |
|---|---|
| 🌐 Global News Collection | Queries international outlets via NewsAPI. Target specific sources (Al Jazeera, BBC, Reuters) or cast wide. Supports boolean operators (AND, OR), language filters, and batch sizes up to 20. |
| 🧹 Cross-Outlet Deduplication | RapidFuzz token_set_ratio at 84% threshold eliminates wire stories republished across dozens of outlets. The most complete version survives. Each remaining article represents a genuinely different perspective. |
| 🧠 Narrative Dissection | Local LLM (Ollama) acts as a senior geopolitical analyst. Extracts stance, emotional tone, intensity, key entities, narrative summary, and underlying motives per article — via structured Pydantic output, no hallucinated formats. |
| 📊 Stance & Bias Mapping | Constrained neutral / pro_<actor> / anti_<actor> schema with regex validation. Enables systematic, comparable bias mapping across outlets and over time. |
| 🎨 Intelligence Reports | Auto-generated dark-themed HTML reports: stance badges, intensity bars, entity tags, motive pills, source links. Open in any browser, no dependencies. |
| 📁 Multi-Format Export | JSON, CSV, XML — for dashboards, NLP pipelines, OSINT tools, or spreadsheet analysis. Timestamped, UTF-8, ready to pipe downstream. |
| 🏗️ Microservice Architecture | Four independent FastAPI services (Pipeline, Scraper, Analysis, Render) via Docker Compose. Each independently scalable, replaceable, and testable. |
| 🔒 Fully Local & Private | Zero egress. Ollama runs on your hardware. Built for sensitive geopolitical research. |
| 💻 CLI Mode | Rich terminal tables for quick analysis without a browser or Docker. |
Four orchestrated microservices. One entry point. Full intelligence cycle.
graph TD
Client(("Client")) -->|"POST /pipeline"| Pipeline
Pipeline["Pipeline Service :8000"] -->|"POST /scrape"| Scraper["Scraper Service :8001"]
Pipeline --> Dedup["HeadlineDeduplicator"]
Pipeline -->|"POST /analyze"| Analysis["Analysis Service :8002"]
Pipeline -->|"POST /render/save"| Render["Render Service :8003"]
Scraper --> NewsAPI["NewsAPI"]
Analysis --> Ollama["Ollama — llama3.1"]
Render --> HTML["./output/render/*.html"]
| Service | Container | Port | Role |
|---|---|---|---|
| Pipeline | pipeline_service |
8000 |
Orchestrates the full cycle. Single entry point. |
| Scraper | scraper_service |
8001 |
NewsAPI integration. Parses into Headline models. |
| Analysis | analysis_service |
8002 |
The intelligence core. Prompt → Ollama → structured Narrative. |
| Render | render_service |
8003 |
Narrative data → dark-themed HTML reports. |
Full project structure
Parallax/
├── docker-compose.yml
├── Dockerfile
├── pyproject.toml
├── uv.lock
├── .env # NEWSAPI_KEY, OLLAMA_HOST
│
├── src/parallax/
│ ├── main.py # CLI entry point
│ ├── container.py # Root DI container (CLI mode)
│ │
│ ├── models/enter/
│ │ ├── headline.py
│ │ ├── narrative.py
│ │ ├── narrativeLLM.py # LLM schema (constrained stance, tone literals)
│ │ └── web/
│ │ ├── pipelinerequest.py
│ │ ├── scraperequest.py
│ │ └── renderrequest.py
│ │
│ ├── interfaces/ # Abstract contracts (Clean Architecture)
│ │ ├── enter/
│ │ │ ├── IScraper.py
│ │ │ ├── IWebScraper.py
│ │ │ ├── INarrativeAnalysis.py
│ │ │ ├── IAnalysisMetrics.py
│ │ │ └── usecases/
│ │ │ ├── IExecutorUseCase.py
│ │ │ ├── IScraperUseCase.py
│ │ │ ├── IAnalysisUseCase.py
│ │ │ └── IRenderUseCase.py
│ │ └── out/
│ │ └── ICliApp.py
│ │
│ ├── scrapers/
│ │ ├── web.py # WebScraper: aggregates all providers
│ │ ├── telegram.py # Telegram scraper (planned)
│ │ └── websites/
│ │ ├── base.py
│ │ └── newsapi.py
│ │
│ ├── analysis/
│ │ ├── engine.py # prompt → Ollama → Narrative
│ │ └── metrics.py # stance/tone distributions
│ │
│ ├── helpers/
│ │ ├── deduplicator.py # RapidFuzz deduplication
│ │ └── modeltofile.py # JSON / CSV / XML export
│ │
│ ├── ui/
│ │ └── app.py # Rich CLI table
│ │
│ └── services/
│ ├── pipeline_service/
│ ├── scraper_service/
│ ├── analysis_service/
│ └── render_service/
│
└── output/render/ # Generated HTML reports
A single POST /pipeline triggers the full cycle.
- Queries NewsAPI via
get_everything()— boolean operators, language filter, relevancy sort, batches up to 20. - Source targeting: compare
al-jazeera-englishvsbbc-newsvsreuterson the same event. - Parses into
Headlinemodels (title, source, URL, description, author, date, UUID). - Secondary local filter ensures articles genuinely contain the search term.
- Discards noise: missing titles, empty descriptions, removed articles.
- Extensible:
WebScraperaggregates anyIScraperimplementation.telegram.pystubbed for future Telegram collection.
The same wire story (AP, Reuters) gets republished by dozens of outlets. Analyzing it 15 times wastes LLM compute and pollutes your intelligence product.
HeadlineDeduplicator:
- Combines
headline + descriptioninto a single comparison string. - Normalizes: lowercase, strip special chars, collapse whitespace, unify quotes.
fuzz.token_set_ratio— resilient to word reordering (critical for news headlines).- 84% threshold → flagged as duplicate. Longer version survives.
- Output: a pruned set where each article represents a genuinely distinct perspective.
The Analysis Service operates the LLM as a geopolitical intelligence analyst.
System prompt: "You are a senior intelligence analyst specialized in geopolitical narrative analysis."
For each batch of deduplicated headlines:
- Builds structured JSON payload (IDs, titles, descriptions, sources).
- Dispatches to Ollama with
temperature: 0— deterministic, reproducible. - Structured output via Pydantic (
NarrativeListResponse) — hallucinated formats are impossible.
What the analyst extracts:
| Field | Type | Purpose |
|---|---|---|
stance |
neutral / pro_<actor> / anti_<actor> |
Ideological alignment. Regex-validated. |
emotional_tone |
alarmist · factual · sympathetic · triumphalist · defeatist |
Emotional framing strategy. |
emotional_intensity |
0.0 – 10.0 |
Charge level. Wire reports: 1–3. Sensationalist: 7–10. |
key_entities |
list[str] |
Who each outlet centers in the narrative. |
narrative_summary |
str |
The framing of the event — not the event itself. |
motives |
list[str] |
Detected agendas: "Humanitarian Framing", "Territorial Legitimization", etc. |
Dark intelligence theme (#020617, Inter typeface). Per-article cards: stance badge, narrative summary, tone + intensity bar, entity tags, motive pills, source link. Saved as timestamped .html to ./output/render/.
ModelToFile → JSON (pretty-printed), CSV (nested fields as JSON strings), XML (<items>/<item>). All timestamped to output/.
- Docker & Docker Compose — Docker Desktop
- NewsAPI Key — free at newsapi.org
git clone https://github.com/leonifrazao/Parallax.git
cd ParallaxCreate .env:
NEWSAPI_KEY=your_newsapi_key_here
OLLAMA_HOST=http://localhost:11434On Linux, you may need
--add-hostor the host's IP instead ofhost.docker.internal.
docker compose exec ollama ollama pull llama3.1
docker compose up --build| Container | URL |
|---|---|
| Pipeline (gateway) | http://localhost:8000 |
| Scraper | http://localhost:8001 |
| Analysis | http://localhost:8002 |
| Render | http://localhost:8003 |
Verify:
curl http://localhost:8000/health
# {"status":"ok","service":"pipeline-service","version":"0.1.0"}# Compare Iran nuclear coverage across ideologically opposed outlets
curl -X POST http://localhost:8000/pipeline \
-H 'Content-Type: application/json' \
-d '{
"query": "Iran nuclear deal",
"limit": 5,
"sources": ["al-jazeera-english", "bbc-news", "reuters", "the-washington-post"],
"tojson": true
}'
# Detect NATO framing divergence between Western and non-Western press
curl -X POST http://localhost:8000/pipeline \
-H 'Content-Type: application/json' \
-d '{
"query": "NATO expansion",
"limit": 10,
"sources": ["bbc-news", "cnn", "al-jazeera-english", "the-hindu"],
"tojson": true
}'| Parameter | Type | Default | Description |
|---|---|---|---|
query |
string |
required | Supports boolean operators: "Iran AND sanctions", "NATO OR OTAN" |
limit |
integer |
10 |
Max headlines after deduplication |
sources |
string[] |
[] |
NewsAPI source IDs. Empty = all sources |
tojson |
boolean |
false |
Export .json to output/ |
[
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"headline": "Iran Resumes Uranium Enrichment Amid Stalled Talks",
"stance": "anti_iran",
"emotional_tone": "alarmist",
"emotional_intensity": 7.2,
"key_entities": ["Iran", "IAEA", "United States", "Uranium Enrichment"],
"narrative_summary": "Western-aligned framing positioning Iran as aggressor, omitting context about sanctions relief failures.",
"motives": ["Security Threat Amplification", "Diplomatic Pressure", "Nuclear Proliferation Fear"],
"url": "https://www.bbc.com/news/...",
"source": "BBC News"
},
{
"id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
"headline": "Iran Exercises Sovereign Right to Nuclear Energy",
"stance": "pro_iran",
"emotional_tone": "sympathetic",
"emotional_intensity": 4.1,
"key_entities": ["Iran", "NPT", "Sovereignty", "Western Powers"],
"narrative_summary": "Frames Iran's program as legitimate under the NPT, emphasizing Western hypocrisy in selective enforcement.",
"motives": ["Sovereignty Defense", "Anti-Western Framing", "Historical Grievance"],
"url": "https://www.aljazeera.com/news/...",
"source": "Al Jazeera English"
}
]An HTML report is also generated at
./output/render/Iran_nuclear_deal_<timestamp>.html.
| Service | Endpoint | Method | Purpose |
|---|---|---|---|
| Scraper | localhost:8001/scrape |
POST | Raw collection only |
| Analysis | localhost:8002/analyze |
POST | Narrative analysis only |
| Render | localhost:8003/render/save |
POST | HTML generation only |
uv run parallaxRich terminal table: ID, Headline, Stance, Tone, Intensity, Source, URL.
| Service | Swagger | Health |
|---|---|---|
| Pipeline | localhost:8000/docs |
GET /health |
| Scraper | localhost:8001/docs |
GET /health |
| Analysis | localhost:8002/docs |
GET /health |
| Render | localhost:8003/docs |
GET /health |
class Headline(BaseModel):
text: str
source: str
url: str | None
description: str | None
author: str | None
published_at: datetime | None
scraped_at: datetime # auto
id: str # UUID, autoclass Narrative(BaseModel):
id: str # matches Headline UUID
headline: str # LLM-reformulated
stance: str # neutral / pro_<actor> / anti_<actor>
emotional_tone: str # alarmist / factual / sympathetic / triumphalist / defeatist
emotional_intensity: float # 0.0 – 10.0
key_entities: list[str]
narrative_summary: str
motives: list[str]
url: str
source: strstance— regex:^(neutral|pro_[a-z0-9_]+|anti_[a-z0-9_]+)$emotional_tone—Literalenum, 5 values onlyemotional_intensity— bounded0.0 ≤ x ≤ 10.0
Structured output via Pydantic prevents hallucinated or inconsistent LLM responses.
Done:
- NewsAPI collection + source targeting
- RapidFuzz cross-outlet deduplication (84% threshold)
- Ollama narrative analysis with structured Pydantic output
- Stance & bias extraction (
pro_*/anti_*with regex validation) - Emotional tone classification (5-class taxonomy)
- HTML intelligence report generation
- Multi-format export (JSON, CSV, XML)
- CLI mode (Rich tables)
- Dependency Injection architecture (IoC containers)
- Docker Compose microservice orchestration
- Health check endpoints
Next (prioritized by value, not wish list):
- PostgreSQL persistence layer — without it, every run is ephemeral. Historical narrative tracking (the most strategically valuable use case) is impossible without persistence. This is the highest-leverage next step.
- Aggregate metrics dashboard — once data persists: stance distributions over time, entity frequency shifts, tone trends across crises. This turns Parallax from a one-shot tool into an ongoing intelligence asset.
- Comparative analysis mode — same event, two outlet groups, side-by-side diff. The core use case for researchers.
- Additional collection sources — Telegram channels, Reddit, Twitter/X. Extends coverage beyond NewsAPI's Western-press bias.
- Multi-language analysis — PT-BR, AR, RU. Needed to properly analyze non-English narratives without translation loss.
Python >= 3.12
FastAPI
Docker & Docker Compose
Ollama (local LLM inference)
- UV — astral-sh/uv
- RapidFuzz — fuzzy string matching
- Pydantic v2 — structured LLM output enforcement
- dependency-injector — IoC containers
- HTTPX — async HTTP client
- Loguru — structured logging
- Rich — terminal output
- BeautifulSoup4 — HTML parsing
If something is broken, open an issue. If you have a meaningful addition — new scraper source, improved analysis prompt, dashboard frontend — fork, branch, and open a PR.
Focus areas where contributions are most impactful:
- New
IScraperimplementations (Telegram, Reddit, RSS feeds) - Improved system prompts for specific conflict domains
- Persistence layer (PostgreSQL + repository pattern already stubbed)
git checkout -b feature/your-feature
git commit -m 'Add your feature'
git push origin feature/your-feature
# Open a Pull RequestUnlicense. See LICENSE.txt. Do what you want with it.
Leoni Frazão — leoni.frazao.oliveira@gmail.com
github.com/leonifrazao/Parallax · linkedin.com/in/leonifrazao