Orchestrating Clean Architecture, Subprocess Scrapers, SQLite FTS5, and Local LLMs (Ollama) inside a Dockerized Python + React Stack.
1. Executive Summary & Design Goals
The BMW AutoTrend Dashboard is a local-first, AI-powered automotive intelligence platform that scrapes, processes, classifies, and visualizes market trends and sentiment for BMW-related news. The system is designed to run completely locally, using a local LLM instance via Ollama for article summarization and entity extraction, backed up by a deterministic rule-based regex fallback engine.
The platform was built with the following design goals:
- Clean Architecture: Decouple the data extraction layer, storage mechanism, analytical computations, and API controllers.
- Deterministic Fallbacks: Ensure the application remains fully functional (using a rule-based regex processor) even when local LLM resources (Ollama) are offline or resource-constrained.
- Local-First & High Performance: Use SQLite FTS5 for instant full-text search, keeping all scraped data, search indexes, and analysis files stored locally without external SaaS API dependencies.
- Plug-and-Play Extensibility: Allow plugging in new scraping adapters (e.g., Autoblog, MotorTrend) by implementing a unified abstract interface.
2. Clean System Architecture
The application adheres to Clean Architecture principles, maintaining a strict unidirectional data flow and isolating business logic from external dependencies.
graph TD
subgraph Frontend [React SPA - Served via Nginx]
UI[Interactive Dashboard Pages]
RC[Recharts Visualizations]
FTS_UI[Spotlight Search UI]
end
subgraph Backend [FastAPI Application]
API[FastAPI Endpoints]
SCH[APScheduler Ingestion & Snapshots]
ING[Ingestion Pipeline]
ANA[Analytics Engine]
AIP[AI Processing Pipeline]
end
subgraph CLI Bridge [Subprocess Execution]
WBC[Webcmd CLI + BMWBLOG Plugin]
end
subgraph Storage [Local Storage]
DB[(SQLite Database)]
FTS[(FTS5 Search Index)]
end
subgraph Local LLM [AI Inference]
OLL[Ollama Server]
end
UI -->|Queries| API
API -->|Reads/Writes| DB
ING -->|Executes| WBC
WBC -->|Scrapes Web Data| BMWBLOG[BMWBLOG Site]
ING -->|Sends content for classification| AIP
AIP -->|Requests JSON| OLL
AIP -->|Regex Fallback| AIP
SCH -->|Triggers| ING
SCH -->|Runs daily| ANA
ANA -->|Computes stats| DB
FTS_UI -->|Queries FTS| API
API -->|FTS Match Query| FTS
- Frontend Layer: A React + TypeScript SPA built with Vite. It interacts with the backend strictly via standardized JSON REST endpoints.
- API Controllers: FastAPI handlers that validate schemas using Pydantic, query storage, and trigger background tasks.
-
Core Business Logic:
- The Ingestion Pipeline orchestrates scraping, duplicate checks, and data ingestion.
- The AI Processing Pipeline performs text analysis, summarization, and tag extraction.
- The Analytics Engine aggregates metrics and maintains daily snapshots.
-
Data Providers: Subprocess wrappers encapsulating the execution of Node.js-based CLI tools (
webcmd). - Database Layer: SQLite managed via SQLAlchemy ORM, enriched with raw SQL database triggers.
3. Ingestion Pipeline & Subprocess WebCMD Scraping
Data ingestion begins with the abstract base class NewsProvider in [providers/base.py]. Concrete providers, such as the BMWBlogProvider in providers/bmwblog.py, are responsible for crawling data from publishers.
Rather than writing custom web scrapers that are brittle and hard to maintain, the backend utilizes the @agentrhq/webcmd CLI tool under the hood. The BMWBlogProvider executes webcmd as a subprocess to retrieve structured article feeds.
The Subprocess Execution Mechanism
# snippet from backend/providers/bmwblog.py
def _run_webcmd(self, args: List[str]) -> str:
cmd = ["webcmd"] + args
try:
# We use shell=True on Windows because webcmd is a script (.ps1 or .cmd)
is_windows = os.name == 'nt'
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
shell=is_windows,
check=True
)
return result.stdout
except subprocess.CalledProcessError as e:
logger.error(f"webcmd execution failed: {result.stderr}")
raise Exception(f"webcmd error: {result.stderr}")
The pipeline executes in two stages:
-
Metadata Fetching: Runs
webcmd bmwblog latest -f jsonto retrieve the latest 10 articles (metadata only: URLs, titles, brief excerpts). -
Deep Article Ingestion: For each new URL (determined by checking the SQLite index), it queries
webcmd bmwblog article <url> -f jsonto extract the full body text, category, author, and scrapes the raw HTML for OpenGraph images (og:image).
4. Hybrid AI Classification Pipeline (Ollama & Regex Fallbacks)
Once full article text is ingested, it is dispatched to ai/processor.py. The processor uses a hybrid system that dynamically checks Ollama's availability before selecting an evaluation engine:
graph TD
A[New Article Ingested] --> B{Is Ollama Server Online?}
B -->|Yes| C[Call Ollama Llama 3.2 API]
C --> D{Parsing JSON Successful?}
D -->|Yes| E[Save AI Classification to Database]
D -->|No| F[Fallback to Regex Rule Engine]
B -->|No| F
F --> G[Extract Entities, Sentiment, & Summaries via Rules]
G --> E
1. Ollama LLM Pipeline
If the Ollama server is online and running llama3.2, the backend posts to /api/generate with a carefully crafted prompt. To ensure deterministic integration, the request forces a structured JSON output:
payload = {
"model": settings.OLLAMA_MODEL,
"prompt": prompt,
"format": "json",
"stream": False,
"options": { "temperature": 0.1 }
}
The model classifies sentiment (Positive, Neutral, Negative), extracts relevant vehicle models (e.g., Neue Klasse, BMW M3), maps technology tags (e.g., Battery Technology, ADAS), and generates short, detailed, and TL;DR summaries.
2. Rule-Based Regex Fallback Pipeline
If Ollama is offline (or fails to return valid JSON), _analyze_with_rules executes. It uses pre-compiled regular expressions to match keywords and tags:
TECHNOLOGY_TAGS = {
"Electric Vehicles": r"\b(ev|evs|electric|zero-emission|zero emission|battery electric|bev)\b",
"Battery Technology": r"\b(battery|batteries|solid-state|cell|cells|rimac)\b",
"Autonomous Driving": r"\b(autonomous|self-driving|driverless|autopilot)\b",
# ...
}
# Sentiment heuristic based on term tallying
pos_count = sum(len(re.findall(rf"\b{word}\b", combined_text)) for word in SENTIMENT_POSITIVE)
neg_count = sum(len(re.findall(rf"\b{word}\b", combined_text)) for word in SENTIMENT_NEGATIVE)
This hybrid model guarantees that database columns are populated with valid categories and tags, and that search capabilities are never compromised by hardware limitations.
5. High-Performance SQLite FTS5 Search Indexing with Database Triggers
For high-speed, local-first search, the project bypasses slow LIKE %query% SQL operations and utilizes SQLite's native FTS5 (Full-Text Search) extension.
Database Schema Initialization and Triggers
In database/connection.py, the application boots the database using SQLAlchemy, but hooks into standard connection events to enforce foreign key constraints (PRAGMA foreign_keys=ON) and manually provisions an FTS5 virtual table + synchronization triggers:
-- FTS5 Virtual Table Configuration
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
excerpt,
content,
content='articles'
);
-- Synchronization Triggers (Insert, Delete, Update)
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, excerpt, content)
VALUES (new.id, new.title, new.excerpt, new.content);
END;
These triggers offload search indexing directly to the SQLite engine. Whenever a new article is committed via SQLAlchemy, SQLite automatically indexes the title, excerpt, and content inside the FTS5 shadow tables.
Relevance-Based Search Endpoint
When a user searches the dashboard, the backend performs a BM25 relevance-ranking query:
# Snippet from backend/api/routes.py
query_str = """
SELECT rowid FROM articles_fts
WHERE articles_fts MATCH :query
ORDER BY bm25(articles_fts)
"""
result = db.execute(text(query_str), {"query": search_term})
This executes in sub-milliseconds, giving the frontend instantaneous "Search-as-you-type" capabilities.
6. Dynamic Analytics Engine & Rolling Trend Computations
The Analytics Engine in analytics/engine.py performs rolling window calculations to determine which automotive trends are gaining traction.
Instead of tracking basic counts, the engine computes a weekly growth rate by comparing mentions of vehicle models and technologies over a rolling 7-day window vs the preceding 7-day window.
# 7-day boundaries
seven_days_ago = today_start - timedelta(days=7)
fourteen_days_ago = today_start - timedelta(days=14)
# Mentions in last 7 days (recent) vs 7-14 days ago (previous)
# Growth calculation:
if prev_cnt == 0:
growth_pct = 100.0 if recent_cnt > 0 else 0.0
else:
growth_pct = ((recent_cnt - prev_cnt) / prev_cnt) * 100.0
The resulting topics are sorted by growth_rate DESC to populate the "Trending Topics" widgets in the UI.
Historical Snapshotting
To prevent CPU-heavy aggregations every time a user loads the page, the application uses APScheduler to execute generate_daily_snapshot near midnight. This function serializes the calculated statistics to JSON and stores it in the analytics_snapshots table, enabling fast rendering of historical timelines over 30 days.
7. Frontend Architecture (React + Vite + Recharts)
The frontend is a single-page application built on a modern React + TypeScript + Vite architecture.
UI Features
- Glassmorphic Theme: A premium dark-mode interface built on CSS variables, custom Outfit typography, and glowing borders.
-
Component Layout: The sidebar and main panels are structured in
layouts/DashboardLayout.tsxfor responsive navigation. -
Visualizations: Uses
Rechartsto display dynamic data:- Area charts for daily article volumes.
- Sentiment timelines mapping Positive/Neutral/Negative trends over a 30-day period.
- Bar charts comparing vehicle model and technology tag popularity.
- Real-time Search: Ingests keypresses, queries the SQLite FTS5 index via the API, and highlights matches with sub-millisecond lag.
8. Docker Orchestration & DevSecOps Insights
The platform is designed to launch with a single command: docker compose up --build. The orchestration configures three networks-isolated, dependent containers:
-
Ollama Container: Pulls and serves LLM models on port
11434. -
FastAPI Backend Container: Built from
docker/backend.Dockerfile. It maps the database to a persistent Docker volumedb_dataat/app/data/autotrend.dbso database records are saved permanently across container restarts. -
Nginx Frontend Container: A multi-stage build that compiles the React application and uses Nginx to serve the static assets on port
80, proxying requests to the backend.
Technical Docker Tip: Node.js Subprocess Requirement
Since the python backend invokes webcmd (an npm library) as a subprocess, the backend Dockerfile must be configured to support a multi-runtime environment. Adding Node.js, npm, and installing @agentrhq/webcmd globally inside the python slim container ensures the ingestion pipeline compiles without error:
FROM python:3.12-slim
WORKDIR /app
# Install Node.js, NPM, and global webcmd CLI scraper
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g @agentrhq/webcmd \
&& webcmd plugin install github:agentrhq/webcmd/bmwblog \
&& rm -rf /var/lib/apt/lists/*
9. Extensibility & Future Scope
The design of the AutoTrend Dashboard makes it easily expandable:
-
Adding New News Outlets: Create a new class implementing the
NewsProviderabstract base class (e.g.AutoblogProvider). Map the scraper plugin commands (e.g.webcmd autoblog latest), and register the provider inrun_ingestion_pipeline. - External LLMs: The AI client in [ai/client.py] can be extended to support remote cloud models (such as the Gemini API or OpenAI API) using environment variables for API keys.
-
Advanced Sentiment Heuristics: The rule-based engine can be upgraded to support VADER or transformer models (like Hugging Face's
distilbert-base-uncased-finetuned-sst-2-english) for deep offline sentiment extraction.
Created as technical documentation for the BMW AutoTrend Dashboard codebase.Github Repo at https://github.com/scha54/BMW-AutoTrend-Dashboard
Top comments (0)