Analyze reviews with AI: from CSV to insights
Load a CSV of reviews, a cheap model labels each one, pandas aggregates the stats, a second request writes the report. The full path from data to insights.
3 min · · development · бизнес
Reading hundreds of reviews by hand takes a long time, and what you really need is the gist: what customers like, what annoys them, and which problems keep coming up. Below is a full review-analysis pipeline via API: a cheap model labels each review (sentiment, topics, quote), pandas computes the stats, and a pricier model writes the final report from the aggregated data.
Overall approach
A two-stage approach saves money:
- Labeling (cheap model): for each review — sentiment (positive/neutral/negative), topics from a fixed list, the main point, and a characteristic quote.
- Report (pricier model): one request with stats and sample quotes, producing conclusions and recommendations.
Most tokens are spent in the first stage, so it uses glm-5.3-flash ($0.04/$0.04 per 1M tokens). The report is a single request, so it can go to claude-sonnet-5 ($0.30/$0.30) at a negligible price difference.
Step 1: load the CSV
import csv
import json
from collections import Counter
import pandas as pd
from openai import OpenAI
client = OpenAI(
base_url="https://api.zukko.pro/v1",
api_key="fetch_your_key",
)
def load_reviews(csv_path: str) -> list[dict]:
"""Expected columns: id, review_text, rating, date"""Sample CSV:
id,review_text,rating,date
1,"Great product, arrived fast",5,2024-01-15
2,"Terrible quality, the seams split after a week",1,2024-01-16
3,"Fine, but expensive",3,2024-01-17
Step 2: label each review
Topics come from a fixed list — otherwise the model will name the same thing differently ("shipping", "courier", "logistics"), and the stats fall apart.
TOPICS = ["quality", "price", "shipping", "packaging", "size", "service", "product description", "other"]
ANALYSIS_PROMPT = f"""Analyze a customer review.
Return ONLY JSON, no markdown:
{{"sentiment": "positive|neutral|negative", "score": 0.5, "topics": ["..."], "main_issue": "...", "key_quote": "..."}}
Rules:
- score — sentiment from -1 (very negative) to 1 (enthusiastic)
- topics — 1–3 topics only from this list: {', '.join(TOPICS)}
- main_issue — one sentence: the main problem or the main strength
- key_quote — an exact quote from the review, no longer than 15 words
"""
def parse_json(text: str):Reviews marked unknown (the model didn't return JSON) are worth re-running or checking by eye.
Step 3: aggregate in pandas
def aggregate(analyzed: list[dict]) -> dict:
df = pd.DataFrame(analyzed)
sentiment = df["sentiment"].value_counts()
topics = Counter(t for ts in df["topics"] for t in ts)
negative = df[df["sentiment"] == "negative"].sort_values("score")
positive = df[df["sentiment"] == "positive"].sort_values("score", ascending=False)
df.to_csv("analyzed_reviews.csv", index=False)
return {
"total": len(df),
"sentiment": sentiment.to_dict(),
"top_topics": topics.most_common(5),
"negative_quotes": negative["key_quote"].head(5).tolist(),
"positive_quotes": positive["key_quote"].head(5).tolist(),
"negative_by_topic": Counter(t for ts in negative["topics"] for t in ts).most_common(5),
}A useful slice is topics in negative reviews specifically (negative_by_topic): it shows what most often causes dissatisfaction, not just what people write about most.
Step 4: the summary report
The model receives only the stats and a few quotes, not the whole review set — a short request:
def generate_report(stats: dict, model: str = "claude-sonnet-5") -> str:
total = stats["total"] or 1
lines = [f"- {k}: {v} ({v / total:.0%})" for k, v in stats["sentiment"].items()]
prompt = f"""Below are stats for {stats['total']} customer reviews.
Sentiment:
{chr(10).join(lines)}
Common topics: {stats['top_topics']}
Topics in negative reviews: {stats['negative_by_topic']}
Quotes from negative reviews:
{chr(10).join('- ' + q for q in stats['negative_quotes'])}
Running the whole thing
def run_pipeline(csv_path: str, report_path: str = "review_report.txt"):
reviews = load_reviews(csv_path)
print(f"Loaded {len(reviews)} reviews")
stats = aggregate(analyze_all(reviews))
report = generate_report(stats)
with open(report_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"Report saved to {report_path}")
return stats, report
if __name__ == "__main__":
run_pipeline("reviews.csv")For thousands of reviews, send first-stage requests in parallel with AsyncOpenAI, capping the number of concurrent requests and retrying on a 429 error.
Cost
Estimate for 1,000 reviews:
Stage 1: glm-5.3-flash ($0.04/$0.04 per 1M)
~300 input and ~100 output tokens per review
(1000 × 300 × 0.04 / 1M) + (1000 × 100 × 0.04 / 1M) = $0.012 + $0.004 = $0.016
Stage 2: claude-sonnet-5 ($0.30/$0.30 per 1M), one request
~2000 input and ~800 output tokens
(2000 × 0.30 / 1M) + (800 × 0.30 / 1M) = $0.0006 + $0.00024 ≈ $0.0008
Total: ≈ $0.017 per 1,000 reviews
If you label with claude-sonnet-5 instead, stage 1 costs 1000 × 400 × 0.30 / 1M = $0.12. Whether the difference is worth it will show up when you check a sample (see the last FAQ question).
Current models and prices: GET https://api.zukko.pro/v1/models or the Models page in your dashboard.
Set up the key
Create a key in the Zukko dashboard and allow both models — glm-5.3-flash and claude-sonnet-5. A $1 monthly limit covers tens of thousands of reviews by the estimate above. See also the quick start and Python examples.
FAQ
What if reviews are in different languages?
Add to the prompt: "the review may be in any language; pick topics from the English list and keep the quote in the original language". Check labeling quality on a few reviews in each language.
How often should I update the report?
It depends on volume. With dozens of reviews a day, a weekly report on new reviews compared with the previous week works well.
Can I split the report by product category?
Yes. Add a category column to the CSV, group the labeled reviews in pandas, and call generate_report for each group separately.
What if the model mislabels reviews?
Manually check a random sample of 50–100 reviews. If errors are systematic, refine the prompt (for example, topic definitions) or compare another model on the same sample.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.