Customer feedback theme extractor on Civo

Build a secure, self-hosted pipeline that ingests customer feedback, uses a local Llama 3 GPU model to extract themes and sentiment, and visualizes insights in a React dashboard; no data leaves your cluster.

5 minutes reading time

Written by

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer @ GoCardless

Shipping customer feedback to an external API is the default move, and also the one that gets flagged in security reviews. Your users are telling you exactly what's broken. They're just doing it in 500 different ways at once. This tutorial builds a self-contained alternative: feedback files land in Civo Object Store, a local LLM running on a Civo GPU node extracts themes, complaints, and feature requests, and a React dashboard surfaces the results. Nothing leaves your cluster.

What we’re building in this tutorial

Throughout this tutorial, we will be focused on 7 core points to build a self-contained customer feedback theme extractor:

  1. Upload a feedback CSV to Civo Object Store
  2. An inference worker running in the cluster polls for new files every 30 seconds
  3. It sends the feedback text to Llama 3 running locally on a Civo GPU node via Ollama
  4. Llama 3 returns structured JSON. Themes, complaints, feature requests, and sentiment
  5. The worker saves those results back to Object Store
  6. A FastAPI service reads the results and aggregates them across all batches
  7. A React dashboard fetches the summary and displays it. Accessible via a public LoadBalancer IP

No customer data leaves your infrastructure at any point.

Customer Feedback Theme Extractor

Prerequisites

Project structure

Before you start, create this folder structure on your machine. You can do it in Finder or run mkdir -p in your terminal; either works. All files that we are going to create in later steps go here.

feedback-extractor/
├── inference-worker/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── prompts.py
│ └── worker.py
├── dashboard-api/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py
└── dashboard-ui/
├── Dockerfile
├── nginx.conf
└── src/
├── App.jsx
└── App.css

Step 1: Install the CLI tools

Check what you already have:

civo version
kubectl version --client

Install anything missing:

brew install civo kubectl

Install boto3 for Object Store uploads:

pip3 install boto3 --break-system-packages

Authenticate the Civo CLI with your API key. Grab it from your dashboard under Profile Security:

civo apikey save default <your-api-key>
civo apikey use default

Step 2: Create the Object Store Bucket

civo objectstore create feedback-data --region NYC1

Once provisioned, get the access key:

civo objectstore show feedback-data --region NYC1

Then fetch the secret:

civo objectstore credential secret --access-key <access-key-from-above>

Export everything:

export CIVO_ACCESS_KEY="<your-access-key>"
export CIVO_SECRET_KEY="<your-secret-key>"
export CIVO_ENDPOINT="https://objectstore.nyc1.civo.com"

Step 3: Upload your first feedback file

In VS Code, create a file at ~/Desktop/feedback-extractor/feedback.csv and paste this in:

id,source,text,date
1,app_store,"The dashboard takes forever to load on large datasets",2024-01-01
2,support,"Can you add dark mode? My eyes hurt after long sessions",2024-01-01
3,support,"Billing page crashed when I tried to update my card",2024-01-02
4,app_store,"Love the new API docs, very clear and well structured",2024-01-02
5,survey,"Please add CSV export to every table not just the main one",2024-01-03
6,support,"Two-factor authentication keeps logging me out randomly",2024-01-03
7,app_store,"The onboarding flow is confusing, couldn't find where to invite teammates",2024-01-04
8,support,"Your Slack integration is broken since last Tuesday",2024-01-04
9,survey,"Would love a mobile app, the mobile browser experience is rough",2024-01-05
10,app_store,"Pricing is not competitive compared to alternatives",2024-01-05
11,support,"Dashboard takes forever to load, please fix this",2024-01-06
12,survey,"Dark mode would be amazing, please add it",2024-01-06
13,app_store,"CSV export is missing from the reports section",2024-01-07
14,support,"Got charged twice this month, billing is broken",2024-01-07
15,survey,"The mobile experience is terrible, need a proper app",2024-01-08
16,app_store,"Slack integration stopped working after your last update",2024-01-08
17,support,"Loading times are unacceptable on our dataset size",2024-01-09
18,survey,"Please add dark mode it would really help usability",2024-01-09
19,support,"Onboarding needs a complete rework, very confusing",2024-01-10
20,app_store,"Two-factor auth is unreliable, keeps kicking me out",2024-01-10

Upload it to the raw/ prefix:

cd ~/Desktop/feedback-extractor
python3 -c "
import boto3
from botocore.config import Config
s3 = boto3.client('s3',
endpoint_url='$CIVO_ENDPOINT',
aws_access_key_id='$CIVO_ACCESS_KEY',
aws_secret_access_key='$CIVO_SECRET_KEY',
config=Config(request_checksum_calculation='when_required', response_checksum_validation='when_required')
)
with open('feedback.csv', 'rb') as f:
s3.put_object(Bucket='feedback-data', Key='raw/feedback.csv', Body=f.read())
print('done')
"

Step 4: Create the Kubernetes cluster

We'll create the cluster using the Civo dashboard, which gives us direct access to GPU node sizes without any additional setup.

  1. Go to dashboard.civo.com and log in
  2. Click KubernetesNew Cluster
  3. Name it “feedback-cluster
  4. Under Node Pools, select Nvidia L40S 40GB Small (an.g1.l40s.kube.x1)
  5. Click Create Cluster
  6. Once ready, download the kubeconfig from the cluster page

Save the kubeconfig:

civo kubernetes config feedback-cluster --save --overwrite --region NYC1

Verify the node is up:

kubectl get nodes

Label it so we can pin Ollama to it later on:

kubectl label node <node-name> gpu=true

Replace <node-name> with the name from kubectl get nodes.

Step 5: Deploy Ollama on the GPU node

Put a brain on the GPU. Once we have created our new GPU node, we want to install our Llama model on it.

In VS Code, create ollama.yaml directly inside ~/Desktop/feedback-extractor/ and paste this in:

apiVersion: v1
kind: Namespace
metadata:
name: inference
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama
namespace: inference
spec:
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
nodeSelector:
gpu: "true"
containers:
- name: ollama
image: ollama/ollama:latest
ports:
- containerPort: 11434
volumeMounts:
- name: ollama-data
mountPath: /root/.ollama
volumes:
- name: ollama-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: ollama
namespace: inference
spec:
selector:
app: ollama
ports:
- port: 11434
targetPort: 11434

Apply it:

kubectl apply -f ~/Desktop/feedback-extractor/ollama.yaml
kubectl -n inference rollout status deployment/ollama

Pull Llama 3 onto the pod. This might take a couple of minutes:

OLLAMA_POD=$(kubectl -n inference get pods -l app=ollama -o jsonpath='{.items[0].metadata.name}')
kubectl -n inference exec -it "$OLLAMA_POD" -- ollama pull llama3

Sanity check:

kubectl -n inference exec -it "$OLLAMA_POD" -- ollama run llama3 "Reply with one word: working"

Step 6: Build and deploy the inference worker

The inference worker is a Python script that runs as a pod inside the cluster in an infinite loop. It has one job: watch the Object Store for new feedback files, process them through the LLM, and save the results back. It has no HTTP endpoints, and nothing talks to it directly; it just runs quietly in the background, picking up files as they arrive and writing results when done.

Open inference-worker/ in VS Code and create the following four files.

inference-worker/requirements.txt

boto3==1.34.0
httpx==0.27.0
pydantic==2.6.0
tenacity==8.2.3

inference-worker/prompts.py

SYSTEM_PROMPT = """You are a product analyst. Your job is to read batches of customer
feedback and extract structured insights. Always respond with valid JSON only —
no markdown, no explanation, just the JSON object."""
def build_extraction_prompt(feedback_texts: list[str]) -> str:
joined = "\n---\n".join(feedback_texts)
return f"""Analyse the following {len(feedback_texts)} customer feedback items.
{joined}
Return a JSON object with this exact structure:
{{
"themes": [
{{"name": "theme name", "count": <int>, "description": "one sentence", "example_quotes": ["quote1", "quote2"]}}
],
"complaints": [
{{"title": "complaint title", "frequency": "high|medium|low", "affected_area": "area"}}
],
"feature_requests": [
{{"feature": "feature name", "demand_score": <1-10>, "context": "brief context"}}
],
"sentiment_breakdown": {{
"positive": <percentage int>,
"neutral": <percentage int>,
"negative": <percentage int>
}}
}}"""

inference-worker/worker.py

import json
import logging
import os
import time
import boto3
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
from prompts import SYSTEM_PROMPT, build_extraction_prompt
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
BUCKET = os.environ["OBJECT_STORE_BUCKET"]
ENDPOINT = os.environ["OBJECT_STORE_ENDPOINT"]
ACCESS_KEY = os.environ["OBJECT_STORE_ACCESS_KEY"]
SECRET_KEY = os.environ["OBJECT_STORE_SECRET_KEY"]
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://ollama.inference.svc.cluster.local:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3")
CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "25"))
RAW_PREFIX = "raw/"
PROCESSED_PREFIX = "processed/"
s3 = boto3.client(
"s3",
endpoint_url=ENDPOINT,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
)
def list_unprocessed_files() -> list[str]:
raw_keys = {
obj["Key"]
for obj in s3.list_objects_v2(Bucket=BUCKET, Prefix=RAW_PREFIX).get("Contents", [])
if obj["Key"].endswith(".csv")
}
done_keys = {
obj["Key"].replace(PROCESSED_PREFIX, RAW_PREFIX).replace(".json", ".csv")
for obj in s3.list_objects_v2(Bucket=BUCKET, Prefix=PROCESSED_PREFIX).get("Contents", [])
}
return sorted(raw_keys - done_keys)
def download_feedback(key: str) -> list[dict]:
import csv, io
body = s3.get_object(Bucket=BUCKET, Key=key)["Body"].read().decode()
return list(csv.DictReader(io.StringIO(body)))
def upload_results(results: dict, source_key: str) -> None:
out_key = source_key.replace(RAW_PREFIX, PROCESSED_PREFIX).replace(".csv", ".json")
s3.put_object(
Bucket=BUCKET,
Key=out_key,
Body=json.dumps(results, indent=2).encode(),
ContentType="application/json",
)
log.info(f"Uploaded results to s3://{BUCKET}/{out_key}")
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def call_ollama(prompt: str) -> dict:
payload = {
"model": OLLAMA_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
"stream": False,
"format": "json",
}
resp = httpx.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=120)
resp.raise_for_status()
return json.loads(resp.json()["message"]["content"])
def merge_chunk_results(chunk_results: list[dict]) -> dict:
merged_themes: dict[str, dict] = {}
merged_complaints: list[dict] = []
merged_requests: dict[str, dict] = {}
sentiment_totals = {"positive": 0, "neutral": 0, "negative": 0}
for result in chunk_results:
for theme in result.get("themes", []):
name = theme["name"].lower()
if name in merged_themes:
merged_themes[name]["count"] += theme["count"]
else:
merged_themes[name] = {**theme}
seen = {c["title"].lower() for c in merged_complaints}
for complaint in result.get("complaints", []):
if complaint["title"].lower() not in seen:
merged_complaints.append(complaint)
for fr in result.get("feature_requests", []):
key = fr["feature"].lower()
if key in merged_requests:
merged_requests[key]["demand_score"] = (
merged_requests[key]["demand_score"] + fr["demand_score"]
) / 2
else:
merged_requests[key] = {**fr}
for s in ("positive", "neutral", "negative"):
sentiment_totals[s] += result.get("sentiment_breakdown", {}).get(s, 0)
n = len(chunk_results)
return {
"themes": sorted(merged_themes.values(), key=lambda t: t["count"], reverse=True),
"complaints": merged_complaints,
"feature_requests": sorted(merged_requests.values(), key=lambda r: r["demand_score"], reverse=True),
"sentiment_breakdown": {k: round(v / n) for k, v in sentiment_totals.items()},
}
def process_file(key: str) -> None:
log.info(f"Processing {key}")
rows = download_feedback(key)
texts = [r["text"] for r in rows if r.get("text")]
chunks = [texts[i : i + CHUNK_SIZE] for i in range(0, len(texts), CHUNK_SIZE)]
chunk_results = []
for idx, chunk in enumerate(chunks, 1):
log.info(f" Chunk {idx}/{len(chunks)} ({len(chunk)} items)")
chunk_results.append(call_ollama(build_extraction_prompt(chunk)))
result = merge_chunk_results(chunk_results)
result["source_file"] = key
result["processed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
result["total_feedback_items"] = len(texts)
upload_results(result, key)
def main():
log.info("Inference worker started")
while True:
pending = list_unprocessed_files()
if not pending:
log.info("No unprocessed files. Sleeping 30s...")
time.sleep(30)
continue
for key in pending:
try:
process_file(key)
except Exception as e:
log.error(f"Failed to process {key}: {e}")
time.sleep(10)
if __name__ == "__main__":
main()

inference-worker/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY worker.py prompts.py ./
CMD ["python", "worker.py"]

Now build and push the image.

Apple Silicon: Always build with --platform linux/amd64. The cluster runs AMD64 and will fail to pull an ARM image with a no match for platform in manifest error.

cd ~/Desktop/feedback-extractor/inference-worker
docker buildx build --platform linux/amd64 \
-t <your-dockerhub-username>/feedback-worker:latest --push

Create the credentials secret:

kubectl -n inference create secret generic objectstore-creds \
--from-literal=bucket="feedback-data" \
--from-literal=endpoint="https://objectstore.nyc1.civo.com" \
--from-literal=access_key="$CIVO_ACCESS_KEY" \
--from-literal=secret_key="$CIVO_SECRET_KEY"

In VS Code, create inference-worker/worker-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: inference-worker
namespace: inference
spec:
replicas: 1
selector:
matchLabels:
app: inference-worker
template:
metadata:
labels:
app: inference-worker
spec:
containers:
- name: worker
image: <your-dockerhub-username>/feedback-worker:latest
env:
- name: OBJECT_STORE_BUCKET
valueFrom:
secretKeyRef:
name: objectstore-creds
key: bucket
- name: OBJECT_STORE_ENDPOINT
valueFrom:
secretKeyRef:
name: objectstore-creds
key: endpoint
- name: OBJECT_STORE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: objectstore-creds
key: access_key
- name: OBJECT_STORE_SECRET_KEY
valueFrom:
secretKeyRef:
name: objectstore-creds
key: secret_key
- name: OLLAMA_URL
value: "http://ollama.inference.svc.cluster.local:11434"

Deploy it:

kubectl apply -f ~/Desktop/feedback-extractor/inference-worker/worker-deployment.yaml
kubectl -n inference rollout status deployment/inference-worker

Tail the logs to confirm it's working:

kubectl -n inference logs -f deployment/inference-worker

You should see:

INFO Inference worker started
INFO Processing raw/feedback.csv
INFO Chunk 1/1 (20 items)
INFO Uploaded results to s3://feedback-data/processed/feedback.json
INFO No unprocessed files. Sleeping 30s...

Step 7: Build and deploy the dashboard API

Open dashboard-api/ in VS Code and create three files.

dashboard-api/requirements.txt

boto3==1.34.0
fastapi==0.110.0
uvicorn[standard]==0.29.0

dashboard-api/main.py

import json
import os
import boto3
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
s3 = boto3.client(
"s3",
endpoint_url=os.environ["OBJECT_STORE_ENDPOINT"],
aws_access_key_id=os.environ["OBJECT_STORE_ACCESS_KEY"],
aws_secret_access_key=os.environ["OBJECT_STORE_SECRET_KEY"],
)
BUCKET = os.environ["OBJECT_STORE_BUCKET"]
def get_all_results() -> list[dict]:
objects = s3.list_objects_v2(Bucket=BUCKET, Prefix="processed/").get("Contents", [])
return [
json.loads(s3.get_object(Bucket=BUCKET, Key=obj["Key"])["Body"].read())
for obj in objects if obj["Key"].endswith(".json")
]
@app.get("/api/summary")
def summary():
all_results = get_all_results()
if not all_results:
return {"message": "No processed data yet", "batches_processed": 0}
theme_map, complaint_map, feature_map = {}, {}, {}
sentiment_totals = {"positive": 0, "neutral": 0, "negative": 0}
total_items = 0
for result in all_results:
total_items += result.get("total_feedback_items", 0)
for theme in result.get("themes", []):
theme_map[theme["name"]] = theme_map.get(theme["name"], 0) + theme["count"]
for complaint in result.get("complaints", []):
complaint_map.setdefault(complaint["title"], complaint)
for fr in result.get("feature_requests", []):
if fr["feature"] in feature_map:
feature_map[fr["feature"]] = (feature_map[fr["feature"]] + fr["demand_score"]) / 2
else:
feature_map[fr["feature"]] = fr["demand_score"]
for s in ("positive", "neutral", "negative"):
sentiment_totals[s] += result.get("sentiment_breakdown", {}).get(s, 0)
n = len(all_results)
return {
"total_feedback_items": total_items,
"batches_processed": n,
"themes": sorted([{"name": k, "count": v} for k, v in theme_map.items()], key=lambda x: x["count"], reverse=True),
"top_complaints": sorted(list(complaint_map.values()), key=lambda c: {"high": 3, "medium": 2, "low": 1}.get(c["frequency"], 0), reverse=True)[:10],
"feature_requests": sorted([{"feature": k, "demand_score": round(v, 1)} for k, v in feature_map.items()], key=lambda x: x["demand_score"], reverse=True)[:10],
"sentiment": {k: round(v / n) for k, v in sentiment_totals.items()},
}
@app.get("/api/batches")
def list_batches():
return get_all_results()
@app.get("/healthz")
def health():
return {"status": "ok"}

dashboard-api/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
cd ~/Desktop/feedback-extractor/dashboard-api
docker buildx build --platform linux/amd64 \
-t <your-dockerhub-username>/feedback-api:latest --push

In VS Code, create dashboard-api/api-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: dashboard-api
namespace: inference
spec:
replicas: 1
selector:
matchLabels:
app: dashboard-api
template:
metadata:
labels:
app: dashboard-api
spec:
containers:
- name: api
image: <your-dockerhub-username>/feedback-api:latest
ports:
- containerPort: 8000
env:
- name: OBJECT_STORE_BUCKET
valueFrom:
secretKeyRef:
name: objectstore-creds
key: bucket
- name: OBJECT_STORE_ENDPOINT
valueFrom:
secretKeyRef:
name: objectstore-creds
key: endpoint
- name: OBJECT_STORE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: objectstore-creds
key: access_key
- name: OBJECT_STORE_SECRET_KEY
valueFrom:
secretKeyRef:
name: objectstore-creds
key: secret_key
---
apiVersion: v1
kind: Service
metadata:
name: dashboard-api
namespace: inference
spec:
selector:
app: dashboard-api
type: LoadBalancer
ports:
- port: 80
targetPort: 8000
kubectl apply -f ~/Desktop/feedback-extractor/dashboard-api/api-deployment.yaml
kubectl -n inference rollout status deployment/dashboard-api
kubectl -n inference get svc dashboard-api

Wait for EXTERNAL-IP to populate, then test:

curl http://<EXTERNAL-IP>/api/summary

Step 8: Build and deploy the React dashboard

Node version: Vite 8 requires Node 20.19+ or 22+. Check with node --version. If you're below that, run nvm install 22 && nvm use 22.

cd ~/Desktop/feedback-extractor/dashboard-ui

npm create vite@latest . -- --template react
npm install
npm install recharts

Open dashboard-ui/src/App.jsx in VS Code and replace the entire contents with:

import { useEffect, useState } from "react";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import "./App.css";
const API_URL = import.meta.env.VITE_API_URL || "http://<your-api-external-ip>";
const SENTIMENT_COLORS = { positive: "#22c55e", neutral: "#94a3b8", negative: "#ef4444" };
const FREQUENCY_COLORS = { high: "#ef4444", medium: "#f97316", low: "#eab308" };
function SentimentBar({ data }) {
return (
<div>
<div className="sentiment-labels">
{Object.entries(data).map(([key, pct]) => (
<span key={key} style={{ color: SENTIMENT_COLORS[key] }}>
{key.charAt(0).toUpperCase() + key.slice(1)}: {pct}%
</span>
))}
</div>
<div className="sentiment-bar">
{Object.entries(data).map(([key, pct]) => (
<div key={key} style={{ width: `${pct}%`, background: SENTIMENT_COLORS[key] }} />
))}
</div>
</div>
);
}
function StatCard({ label, value }) {
return (
<div className="stat-card">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
);
}
export default function App() {
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/summary`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setSummary(await res.json());
setLastUpdated(new Date());
setError(null);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const t = setInterval(load, 30000);
return () => clearInterval(t);
}, []);
if (loading) return <div className="fullscreen-center"><div className="spinner" /><p>Loading...</p></div>;
if (error) return <div className="fullscreen-center"><p>⚠ {error}</p><button onClick={load}>Retry</button></div>;
if (!summary || summary.message) return <div className="fullscreen-center"><p>No processed feedback yet.</p></div>;
return (
<div className="dashboard">
<header className="dashboard-header">
<div>
<h1>Customer Feedback Intelligence</h1>
<p className="header-meta">
Processed on Civo GPU · Last updated {lastUpdated?.toLocaleTimeString()}
</p>
</div>
<button className="refresh-btn" onClick={load}>↻ Refresh</button>
</header>
<div className="stats-row">
<StatCard label="Feedback Items" value={summary.total_feedback_items.toLocaleString()} />
<StatCard label="Batches" value={summary.batches_processed} />
<StatCard label="Themes" value={summary.themes.length} />
<StatCard label="Feature Requests" value={summary.feature_requests.length} />
</div>
<section className="card">
<h2>Sentiment</h2>
<SentimentBar data={summary.sentiment} />
</section>
<section className="card">
<h2>Top Themes</h2>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={summary.themes.slice(0, 8)} layout="vertical" margin={{ left: 10, right: 20 }}>
<XAxis type="number" tick={{ fontSize: 12 }} />
<YAxis type="category" dataKey="name" width={170} tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="count" fill="#6366f1" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</section>
<div className="two-col">
<section className="card">
<h2>Complaints</h2>
<ul className="complaint-list">
{summary.top_complaints.map((c, i) => (
<li key={i} className="complaint-item">
<span className="badge" style={{ background: FREQUENCY_COLORS[c.frequency] }}>
{c.frequency}
</span>
<div className="complaint-body">
<span className="complaint-title">{c.title}</span>
<span className="complaint-area">{c.affected_area}</span>
</div>
</li>
))}
</ul>
</section>
<section className="card">
<h2>Feature Requests</h2>
<table className="feature-table">
<thead><tr><th>Feature</th><th>Demand</th></tr></thead>
<tbody>
{summary.feature_requests.map((fr, i) => (
<tr key={i}>
<td>{fr.feature}</td>
<td>
<div className="demand-wrap">
<div className="demand-track">
<div className="demand-fill" style={{ width: `${fr.demand_score * 10}%` }} />
</div>
<span className="demand-score">{fr.demand_score}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
</div>
);
}

Build the production bundle:

cd ~/Desktop/feedback-extractor/dashboard-ui
npm run build

In VS Code, create dashboard-ui/Dockerfile:

FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

Create dashboard-ui/nginx.conf:

server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://dashboard-api.inference.svc.cluster.local:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
docker buildx build --platform linux/amd64 \
-t <your-dockerhub-username>/feedback-ui:latest --push

In VS Code, create dashboard-ui/ui-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: dashboard-ui
namespace: inference
spec:
replicas: 1
selector:
matchLabels:
app: dashboard-ui
template:
metadata:
labels:
app: dashboard-ui
spec:
containers:
- name: ui
image: <your-dockerhub-username>/feedback-ui:latest
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: dashboard-ui
namespace: inference
spec:
selector:
app: dashboard-ui
type: LoadBalancer
ports:
- port: 80
targetPort: 80
kubectl apply -f ~/Desktop/feedback-extractor/dashboard-ui/ui-deployment.yaml
kubectl -n inference get svc dashboard-ui

Open the EXTERNAL-IP in your browser.

Step 9: Watch it all come together

Upload a second feedback batch:

cd ~/Desktop/feedback-extractor
python3 -c "
import boto3
from botocore.config import Config
s3 = boto3.client('s3',
endpoint_url='$CIVO_ENDPOINT',
aws_access_key_id='$CIVO_ACCESS_KEY',
aws_secret_access_key='$CIVO_SECRET_KEY',
config=Config(request_checksum_calculation='when_required', response_checksum_validation='when_required')
)
with open('feedback.csv', 'rb') as f:
s3.put_object(Bucket='feedback-data', Key='raw/feedback2.csv', Body=f.read())
print('uploaded')
"

Watch the worker pick it up:

kubectl -n inference logs -f deployment/inference-worker

Within 30 seconds, it processes the file, and the dashboard updates automatically.

Front-end view

The dashboard gives you a summary of everything our LLM found across all your feedback batches. The stats at the top show total items processed and how many batches have run. Below that, the sentiment bar breaks down the emotional tone across your entire dataset. The themes chart ranks the most frequently mentioned topics by mention count, and the two columns at the bottom surface your top complaints by severity and feature requests by demand score. Everything your users are asking for, ranked and ready to act on.

Customer Feedback Theme Extractor front end view

Summary

What you’ve built is a private feedback intelligence platform. A system that takes raw customer feedback and automatically turns it into structured, actionable insights without any data leaving your infrastructure. Drop a CSV into the Object store and have your Llama 3 model classify the feedback into themes, rank complaints by severity, surface feature requests by demand score, and break down overall sentiment, all running locally on your own GPU node.

The same pattern scales to anything text-heavy: support tickets, app store reviews, NPS responses, sales call transcripts. Swap the model, adjust the prompt, and point it at a different bucket prefix. The GPU node is the only moving cost, scale it to zero between runs, and you're paying for inference time only.

Mostafa Ibrahim
Mostafa Ibrahim

Software Engineer @ GoCardless

Mostafa Ibrahim is a software engineer and technical writer specializing in developer-focused content for SaaS and AI platforms. He currently works as a Software Engineer at GoCardless, contributing to production systems and scalable payment infrastructure.

Alongside his engineering work, Mostafa has written more than 200 technical articles reaching over 500,000 readers. His content covers topics including Kubernetes deployments, AI infrastructure, authentication systems, and retrieval-augmented generation (RAG) architectures.

View author profile