Products collect hundreds of reviews across Shopee, Lazada, Amazon, TikTok Shop and your own store, often in several languages. Star ratings show that something is wrong but not what. This guide uses a local language model to read every review and record its overall sentiment, the product aspects it praises or criticises, and the concrete problems it mentions. A report per product then shows the most criticised aspects, reviews whose rating does not match the text, and an AI summary of the main problems with suggested fixes. Everything runs on your own server, so customer texts are not sent to a third party.
apt install -y python3-venv
python3 -m venv /opt/reviews
/opt/reviews/bin/pip install ollama numpy scikit-learn
ollama pull qwen2.5:7b
Ollama setup is covered in running a local LLM with Ollama. numpy and scikit-learn are not needed for this guide, but the topic analysis in the companion guide uses them.
Export your reviews to a UTF-8 CSV file. The columns id and text are required; sku, rating (1 to 5) and date are optional but make the report more useful:
id,sku,rating,date,text
R1001,LS-01,2,2026-09-14,"The stand wobbles when I type, screws came loose after a week."
R1002,LS-01,5,2026-09-15,"Sturdy, looks great on my desk, easy to fold."
R1003,BT-07,1,2026-09-15,"左耳机三天就没声音了,客服也没回复。"
cat > /opt/reviews/reviews.py <<'EOF2'
import csv, json, os, sys
from collections import Counter, defaultdict
import ollama
MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANGUAGE = os.environ.get("REPORT_LANG", "English") # language of issues, summaries and the report
ASPECTS = ["quality", "size_fit", "function", "durability", "ease_of_use",
"delivery", "packaging", "price_value", "customer_service", "other"]
SENT = {"type": "string", "enum": ["positive", "neutral", "negative"]}
SCHEMA = {
"type": "object",
"properties": {
"sentiment": SENT,
"aspects": {"type": "array", "items": {"type": "object", "properties": {
"aspect": {"type": "string", "enum": ASPECTS}, "sentiment": SENT}, "required": ["aspect", "sentiment"]}},
"issues": {"type": "array", "items": {"type": "string"}},
"summary": {"type": "string"},
},
"required": ["sentiment", "aspects", "issues", "summary"],
}
SYSTEM = ("You analyse customer reviews for an online shop. The review can be in any language. "
"Give the overall sentiment, and the sentiment of each product aspect the review actually mentions. "
f"List concrete problems as short phrases in {LANGUAGE} (empty list if there are none) "
f"and write a one-sentence summary in {LANGUAGE}. Do not invent anything that is not in the review.")
def ask(system, text, schema=None):
r = ollama.chat(model=MODEL, format=schema, options={"temperature": 0}, messages=[
{"role": "system", "content": system}, {"role": "user", "content": text}])
return r.message.content
src, out = sys.argv[1], "reviews_tagged.csv"
done = {}
if os.path.exists(out):
with open(out, encoding="utf-8-sig") as f:
done = {r["id"]: r for r in csv.DictReader(f)}
with open(src, encoding="utf-8-sig") as f:
reviews = [r for r in csv.DictReader(f) if r.get("text", "").strip()]
fields = ["id", "sku", "rating", "sentiment", "aspects", "issues", "summary", "text"]
new_file = not os.path.exists(out)
with open(out, "a", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=fields)
if new_file:
w.writeheader()
for r in reviews:
if r["id"] in done:
continue
d = json.loads(ask(SYSTEM, r["text"][:4000], SCHEMA))
row = {"id": r["id"], "sku": r.get("sku", ""), "rating": r.get("rating", ""), "sentiment": d["sentiment"],
"aspects": "; ".join(f"{a['aspect']}:{a['sentiment']}" for a in d["aspects"]),
"issues": "; ".join(i.strip() for i in d["issues"] if i.strip()), "summary": d["summary"], "text": r["text"]}
w.writerow(row)
f.flush()
done[r["id"]] = row
print(r["id"], d["sentiment"], flush=True)
by_sku = defaultdict(list)
for row in done.values():
by_sku[row["sku"] or "-"].append(row)
with open("report.md", "w", encoding="utf-8") as f:
for sku, rows in sorted(by_sku.items(), key=lambda x: -len(x[1])):
sent = Counter(r["sentiment"] for r in rows)
neg = Counter(a.split(":")[0] for r in rows for a in r["aspects"].split("; ") if a.endswith(":negative"))
issues = [i for r in rows for i in r["issues"].split("; ") if i]
odd = [r["id"] for r in rows if r["rating"].isdigit() and ((int(r["rating"]) >= 4 and r["sentiment"] == "negative")
or (int(r["rating"]) <= 2 and r["sentiment"] == "positive"))]
f.write(f"## {sku}: {len(rows)} reviews\n\n")
f.write(f"- Positive {sent['positive']}, neutral {sent['neutral']}, negative {sent['negative']}\n")
f.write("- Most criticised aspects: " + (", ".join(f"{a} ({n})" for a, n in neg.most_common(5)) or "none") + "\n")
f.write("- Rating does not match the text: " + (", ".join(odd) or "none") + "\n\n")
if issues:
f.write(ask(f"You are a product manager. Group these customer complaints into the main problems, "
f"most frequent first, and suggest one concrete fix for each. Answer in {LANGUAGE}, briefly.",
"\n".join(issues[:300])) + "\n\n")
print("report:", sku, flush=True)
print("done: reviews_tagged.csv, report.md")
EOF2
The JSON schema forces the model to answer in a fixed structure: one of three sentiments, aspects from your list, and a list of issues. Finished reviews are saved immediately, and a second run skips them, so you can stop and resume at any time or add new reviews to the same CSV later.
cd /data/reviews
REPORT_LANG="Simplified Chinese" nohup /opt/reviews/bin/python /opt/reviews/reviews.py reviews.csv > reviews.log 2>&1 &
tail -f reviews.log
On a CPU server, a 7B model needs a few seconds to several tens of seconds per review, depending on its length; run large exports overnight or use a smaller model such as qwen2.5:3b with MODEL=qwen2.5:3b. The results are reviews_tagged.csv (one row per review) and report.md (one section per product).
reviews_tagged.csv by product and negative sentiment to read the original reviews behind each problem.Adapt the ASPECTS list to your category, for example fabric, size_fit and colour for clothing, or battery, connectivity and sound for electronics.
Export new reviews every month, run the script on the same file, and compare the reports over time. To discover problems you did not expect, group the reviews into topics as described in finding what customers talk about with embeddings. To answer reviews faster, see drafting replies to customer reviews with AI. What customers praise most is also good material for your product listings.
For clear reviews, sentiment and aspects are reliable. Sarcasm, mixed reviews and very short texts such as "ok" are harder. Check 20 random reviews against the results before you rely on the numbers, and keep the temperature at 0 for consistent answers.
Yes. Put each ticket or conversation in the text column and replace the aspects with your support categories, such as billing, delivery, setup or account.
Many AI APIs support structured output, but the reviews then leave your server. Check your privacy obligations, the provider's terms and its supported regions first.
Still stuck after following these steps? Open a support ticket and the IMIDC 24/7 technical team will help. Include the server IP, the model you use, the first lines of your CSV without personal data and the error output so we can pinpoint the issue faster.