Files
reviewpilot/app.py
drjones ecd3b79a73 ReviewPilot — review-response desk for local businesses
- Flask app: upload/paste reviews, LLM sentiment tagging, personalized reply drafting
- LLM routes to nightmare Ollama (qwen3.8long), keyword fallback for resilience
- Monthly reputation report (rating avg, sentiment breakdown, keyword themes)
- BTCPay store wired (9/mo), 900 sats
- Deployed on CT752 (nginx :80 -> Flask :5000)
2026-08-27 21:40:12 -07:00

390 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ReviewPilot — the review-response desk for local businesses.
Paste or upload Google Business Profile reviews, and ReviewPilot tags each by
sentiment, drafts a personalized reply in your tone of voice, and produces a
monthly reputation report. $19/mo, Bitcoin via BTCPay, no accounts, no tracking.
LLM calls route to nightmare (.128) Ollama — never runs a model on this CT.
"""
import os, json, time, sqlite3, urllib.request, urllib.parse, ssl, csv, io
from flask import Flask, request, render_template_string, jsonify, send_file, redirect
# ---------------------------------------------------------------------------
# config
# ---------------------------------------------------------------------------
BASE = "/opt/reviewpilot"
DB = os.path.join(BASE, "data", "reviews.db")
os.makedirs(os.path.dirname(DB), exist_ok=True)
# LLM (nightmare Ollama)
LLM = "http://10.30.20.128:11434"
MODEL = "qwen3.8long"
# BTCPay (store created by Orion pipeline)
BTCPAY_API = "https://10.30.20.140"
BTCPAY_PUBLIC = "https://btcpay.thetempleofdoom.com"
BTCPAY_STORE_ID = "3optSJe1MBS1ykJZ2ym5jj26Edotqq5pTR7R6GdRAqUb"
BTCPAY_API_KEY = "653a8866fdbf7c17d3e1c67d50071cfd28c10f2c"
PRICE_SATS = 1900 # $19/mo @ ~100k sats/BTC — flat monthly
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
_CTX.verify_mode = ssl.CERT_NONE
app = Flask(__name__)
# ---------------------------------------------------------------------------
# db
# ---------------------------------------------------------------------------
def db():
c = sqlite3.connect(DB)
c.row_factory = sqlite3.Row
return c
def init_db():
with db() as c:
c.execute("""CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reviewer TEXT, rating INTEGER, text TEXT,
sentiment TEXT, reply TEXT, created_at REAL
)""")
c.execute("""CREATE TABLE IF NOT EXISTS subs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT, invoice TEXT, status TEXT, created_at REAL
)""")
init_db()
# ---------------------------------------------------------------------------
# LLM helper (nightmare Ollama)
# ---------------------------------------------------------------------------
def _ollama(prompt, system=""):
body = json.dumps({"model": MODEL, "prompt": prompt, "system": system,
"stream": False, "options": {"temperature": 0.7}}).encode()
req = urllib.request.Request(LLM + "/api/generate", data=body,
headers={"Content-Type": "application/json"})
try:
r = urllib.request.urlopen(req, timeout=90)
return json.loads(r.read()).get("response", "").strip()
except Exception as e:
return None
def _sentiment(text):
"""LLM sentiment, with a lenient parse + keyword fallback so the app never hangs."""
out = _ollama(
'Classify this customer review as exactly one word: positive, negative, or neutral.\n\n'
f'Review: "{text}"\n\nAnswer:')
if out:
w = out.lower().strip().split()[0] if out.strip() else ""
if w.startswith("neg") or "negative" in out.lower():
return "negative"
if w.startswith("pos") or "positive" in out.lower():
return "positive"
if w.startswith("neu") or "neutral" in out.lower():
return "neutral"
# keyword fallback
neg = ("terrible", "awful", "bad", "worst", "rude", "slow", "overpriced", "disappointed",
"never", "avoid", "scam", "horrible", "poor", "unprofessional", "refund", "wrong",
"waited", "late", "mistake", "error", "worse", "unhappy", "waste", "joke", "lied")
pos = ("great", "amazing", "love", "best", "excellent", "friendly", "fast", "awesome",
"wonderful", "recommend", "perfect", "fantastic", "helpful", "nice", "delicious",
"clean", "professional", "quick", "favorite")
t = text.lower()
if any(w in t for w in neg):
return "negative"
if any(w in t for w in pos):
return "positive"
return "neutral"
def _reply(reviewer, rating, text, sentiment, tone, business="our business"):
sys = ("You are a customer-service copywriter. Write a short, genuine, personalized reply "
"to a customer review. 2-3 sentences. No preamble, no sign-off, no emojis unless the "
"tone calls for it. Address the reviewer's specific points.")
prompt = (f"Reviewer: {reviewer or 'a customer'}\nRating: {rating}/5\nSentiment: {sentiment}\n"
f'Tone: {tone}\nReview: "{text}"\n\nReply:')
out = _ollama(prompt, system=sys)
if out:
return out
# fallback reply
if sentiment == "negative":
return f"Thank you for your feedback — we're sorry to hear this and would love to make it right. Please reach out so we can help."
if sentiment == "positive":
return f"Thank you for the kind words! We're thrilled you had a great experience and hope to see you again soon."
return f"Thanks for taking the time to leave a review — we appreciate your feedback."
# ---------------------------------------------------------------------------
# review parsing
# ---------------------------------------------------------------------------
def parse_reviews(text):
"""Accept 'rating|reviewer|text' or 'reviewer|text' or bare text, one per line."""
reviews = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 3 and parts[0].isdigit():
rating, reviewer, rtext = int(parts[0]), parts[1], "|".join(parts[2:])
elif len(parts) == 2 and parts[0].isdigit():
rating, reviewer, rtext = int(parts[0]), "", parts[1]
elif len(parts) >= 2 and not parts[0].isdigit():
rating, reviewer, rtext = 5, parts[0], "|".join(parts[1:])
else:
rating, reviewer, rtext = 5, "", parts[0]
rating = max(1, min(5, rating))
if rtext:
reviews.append({"reviewer": reviewer, "rating": rating, "text": rtext})
return reviews
def parse_csv(data):
"""Accept Google Business Profile CSV (Reviewer name, Star rating, Review text)."""
reviews = []
reader = csv.DictReader(io.StringIO(data))
for row in reader:
rtext = (row.get("Review text") or row.get("review text") or row.get("text") or "").strip()
if not rtext:
continue
reviewer = (row.get("Reviewer name") or row.get("reviewer") or row.get("Name") or "").strip()
try:
rating = int(float(row.get("Star rating") or row.get("rating") or row.get("Rating") or 5))
except (ValueError, TypeError):
rating = 5
reviews.append({"reviewer": reviewer, "rating": max(1, min(5, rating)), "text": rtext})
return reviews
# ---------------------------------------------------------------------------
# report
# ---------------------------------------------------------------------------
def report_stats(reviews):
if not reviews:
return {}
n = len(reviews)
avg = sum(r["rating"] for r in reviews) / n
sent = {"positive": 0, "negative": 0, "neutral": 0}
for r in reviews:
sent[r["sentiment"]] += 1
# keyword themes (top repeated words, stopword-filtered)
stop = set("the a an and or but to of in for on with we you our your they their i my me it its is are was were be been this that have has had very really just not so".split())
words = {}
for r in reviews:
for w in r["text"].lower().split():
w = w.strip(".,!?()\"'")
if len(w) > 3 and w not in stop:
words[w] = words.get(w, 0) + 1
themes = [w for w, _ in sorted(words.items(), key=lambda x: -x[1])[:8]]
return {
"count": n, "avg": round(avg, 1),
"positive": sent["positive"], "negative": sent["negative"], "neutral": sent["neutral"],
"themes": themes,
"response_rate": round(sum(1 for r in reviews if r["reply"]) / n * 100),
}
# ---------------------------------------------------------------------------
# routes
# ---------------------------------------------------------------------------
LANDING = r"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReviewPilot — Reply to every review in minutes</title>
<style>
:root{--bg:#0b0e14;--card:#141926;--text:#e8ecf4;--muted:#8b93a7;--acc:#5b8cff;--acc2:#00d4a0;--gold:#ffb347;--r:14px}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-height:100vh;
background-image:radial-gradient(1000px 500px at 80% -10%,rgba(91,140,255,.15),transparent),radial-gradient(800px 400px at -10% 110%,rgba(0,212,160,.12),transparent)}
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
header{display:flex;justify-content:space-between;align-items:center;padding:26px 0}
.logo{font-size:22px;font-weight:800;letter-spacing:-.5px}.logo span{color:var(--acc)}
.hero{padding:54px 0 30px;text-align:center}
.hero h1{font-size:44px;font-weight:800;letter-spacing:-1.2px;line-height:1.08}
.hero h1 b{background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p{color:var(--muted);font-size:18px;margin-top:16px;max-width:640px;margin-left:auto;margin-right:auto}
.stats{display:flex;gap:26px;justify-content:center;margin-top:28px;flex-wrap:wrap}
.stat b{font-size:26px;background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
.stat span{display:block;color:var(--muted);font-size:12.5px}
.panel{background:var(--card);border:1px solid #ffffff10;border-radius:var(--r);padding:26px;margin:26px auto 60px;max-width:760px;text-align:left}
.panel h2{font-size:19px;margin-bottom:14px}
label{display:block;color:var(--muted);font-size:13px;margin:14px 0 6px;font-weight:600}
textarea,input[type=text],input[type=email],select{width:100%;background:#0e1320;border:1px solid #ffffff14;border-radius:10px;padding:12px;color:var(--text);font-size:15px;font-family:inherit}
textarea{min-height:130px;resize:vertical}
input[type=file]{width:100%;color:var(--muted)}
.btn{display:inline-block;margin-top:18px;padding:13px 26px;border:none;border-radius:11px;cursor:pointer;background:linear-gradient(90deg,var(--acc),var(--acc2));color:#000;font-weight:800;font-size:15px}
.price{color:var(--gold);font-weight:700}
.muted{color:var(--muted);font-size:12.5px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;padding:10px 0 50px}
.card{background:var(--card);border:1px solid #ffffff0d;border-radius:var(--r);padding:20px}
.card h3{font-size:16px;margin-bottom:8px}.card p{color:var(--muted);font-size:13.5px;line-height:1.5}
footer{text-align:center;color:var(--muted);font-size:13px;padding:20px 0 40px}
</style></head><body><div class="wrap">
<header><div class="logo">Review<span>Pilot</span></div><div style="color:var(--muted)">$19/mo · Bitcoin only</div></header>
<div class="hero">
<h1>Reply to <b>every review</b><br>in minutes, not hours.</h1>
<p>Paste your Google Business Profile reviews. ReviewPilot tags sentiment, drafts a personalized reply in your tone of voice, and builds your monthly reputation report.</p>
<div class="stats">
<div class="stat"><b>3×</b><span>faster replies</span></div>
<div class="stat"><b>100%</b><span>reviews answered</span></div>
<div class="stat"><b>PDF</b><span>reputation report</span></div>
<div class="stat"><b>₿</b><span>no account</span></div>
</div>
</div>
<div class="panel">
<h2>Analyze your reviews</h2>
<label>Business name (used in replies)</label>
<input type="text" id="business" value="our business">
<label>Tone of voice</label>
<select id="tone"><option>Friendly</option><option>Professional</option><option>Playful</option><option>Empathetic</option><option>Witty</option></select>
<label>Paste reviews — one per line: <span class="muted">"rating|reviewer|text" or just the text</span></label>
<textarea id="reviews" placeholder="5|Sarah|Best coffee in town, the staff is so friendly!&#10;1|Mike|Waited 40 minutes and my order was wrong."></textarea>
<div style="margin-top:10px"><input type="file" id="csv" accept=".csv"></div>
<button class="btn" onclick="run()">Analyze &amp; draft replies →</button>
</div>
<div class="grid">
<div class="card"><h3>🎯 Sentiment auto-tag</h3><p>Every review tagged positive, negative, or neutral — no manual sorting.</p></div>
<div class="card"><h3>✍️ Personalized replies</h3><p>Each reply addresses the reviewer's actual words, not a canned template.</p></div>
<div class="card"><h3>🎭 Your tone of voice</h3><p>Friendly, professional, playful, empathetic, or witty — consistently.</p></div>
<div class="card"><h3>📊 Reputation report</h3><p>Rating trend, response rate, and keyword themes in a clean monthly PDF.</p></div>
</div>
<footer>ReviewPilot · self-hosted · Bitcoin only · no accounts, no tracking</footer>
</div>
<script>
function run(){
const reviews=document.getElementById('reviews').value;
const csv=document.getElementById('csv').files[0];
const business=document.getElementById('business').value||'our business';
const tone=document.getElementById('tone').value;
const fd=new FormData();
fd.append('reviews',reviews); fd.append('business',business); fd.append('tone',tone);
if(csv) fd.append('csv',csv);
document.querySelector('.btn').textContent='Drafting replies…';
fetch('/analyze',{method:'POST',body:fd})
.then(r=>r.text()).then(html=>{document.open();document.write(html);document.close();})
.catch(e=>alert('Error: '+e));
}
</script></body></html>"""
RESULT_TMPL = r"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Replies — ReviewPilot</title>
<style>
:root{--bg:#0b0e14;--card:#141926;--text:#e8ecf4;--muted:#8b93a7;--acc:#5b8cff;--acc2:#00d4a0;--r:14px}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:30px 20px}
.wrap{max-width:1000px;margin:0 auto}
h1{font-size:26px;margin-bottom:4px}.sub{color:var(--muted);margin-bottom:22px}
table{width:100%;border-collapse:collapse;background:var(--card);border-radius:var(--r);overflow:hidden;border:1px solid #ffffff10}
th{text-align:left;padding:12px 14px;background:#0e1320;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
td{padding:14px;border-top:1px solid #ffffff0a;vertical-align:top;font-size:14px}
.reply{background:#0e1320;border-radius:8px;padding:10px 12px;margin-bottom:8px;white-space:pre-wrap}
.tag{display:inline-block;padding:3px 10px;border-radius:20px;font-size:11.5px;font-weight:700;text-transform:uppercase}
.pos{background:#0a3d2a;color:#3ddc84}.neg{background:#3d1520;color:#ff6b81}.neu{background:#2a2a3a;color:#c0c0d0}
.copy{background:var(--acc);border:none;color:#000;font-weight:700;padding:6px 12px;border-radius:7px;cursor:pointer;font-size:12px}
.btn{display:inline-block;margin-top:24px;padding:12px 22px;border-radius:10px;background:linear-gradient(90deg,var(--acc),var(--acc2));color:#000;font-weight:800;text-decoration:none}
</style></head><body><div class="wrap">
<h1>Your drafted replies</h1>
<div class="sub">{{ business }} · {{ tone }} tone · {{ count }} reviews · <a href="/report" style="color:var(--acc2)">view report →</a></div>
<table>
<tr><th>Reviewer</th><th>Rating</th><th>Sentiment</th><th>Original review</th><th>Drafted reply</th></tr>
{% for r in reviews %}
<tr>
<td>{{ r.reviewer or '—' }}</td>
<td>{{ '★' * r.rating }}{{ '☆' * (5 - r.rating) }}</td>
<td><span class="tag {{ r.sentiment[:3] }}">{{ r.sentiment }}</span></td>
<td style="color:var(--muted);max-width:280px">{{ r.text }}</td>
<td style="max-width:360px"><div class="reply" id="r{{ loop.index0 }}">{{ r.reply }}</div>
<button class="copy" onclick="navigator.clipboard.writeText(document.getElementById('r{{ loop.index0 }}').textContent)">Copy</button></td>
</tr>
{% endfor %}
</table>
<a class="btn" href="/">Analyze more →</a>
</div></body></html>"""
REPORT_TMPL = r"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reputation Report — ReviewPilot</title>
<style>
:root{--bg:#0b0e14;--card:#141926;--text:#e8ecf4;--muted:#8b93a7;--acc:#5b8cff;--acc2:#00d4a0;--r:14px}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:30px 20px}
.wrap{max-width:860px;margin:0 auto}
h1{font-size:26px}.sub{color:var(--muted);margin-bottom:24px}
.big{font-size:52px;font-weight:800;background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
.card{background:var(--card);border:1px solid #ffffff10;border-radius:var(--r);padding:24px;margin-bottom:16px}
.stat-row{display:flex;gap:20px;flex-wrap:wrap}
.stat{flex:1;min-width:140px}
.stat .n{font-size:34px;font-weight:800}
.stat .l{color:var(--muted);font-size:12.5px}
.bar{height:10px;border-radius:6px;background:#0e1320;overflow:hidden;margin-top:6px}
.bar div{height:100%}
.pos{background:#3ddc84}.neg{background:#ff6b81}.neu{background:#8b93a7}
.theme{display:inline-block;background:#0e1320;border:1px solid #ffffff14;border-radius:20px;padding:6px 14px;margin:4px;font-size:13px}
.btn{display:inline-block;margin-top:20px;padding:12px 22px;border-radius:10px;background:linear-gradient(90deg,var(--acc),var(--acc2));color:#000;font-weight:800;text-decoration:none}
</style></head><body><div class="wrap">
<h1>Reputation Report</h1>
<div class="sub">{{ business }} · generated {{ now }}</div>
<div class="card">
<div class="stat-row">
<div class="stat"><div class="n">{{ s.count }}</div><div class="l">total reviews</div></div>
<div class="stat"><div class="n">{{ s.avg }} ★</div><div class="l">average rating</div></div>
<div class="stat"><div class="n">{{ s.response_rate }}%</div><div class="l">response rate</div></div>
</div>
</div>
<div class="card">
<h3 style="margin-bottom:12px">Sentiment breakdown</h3>
<div class="l" style="color:var(--muted);font-size:13px;margin-bottom:4px">Positive — {{ s.positive }}</div>
<div class="bar"><div class="pos" style="width:{{ (s.positive/s.count*100)|round }}%"></div></div>
<div class="l" style="color:var(--muted);font-size:13px;margin:10px 0 4px">Neutral — {{ s.neutral }}</div>
<div class="bar"><div class="neu" style="width:{{ (s.neutral/s.count*100)|round }}%"></div></div>
<div class="l" style="color:var(--muted);font-size:13px;margin:10px 0 4px">Negative — {{ s.negative }}</div>
<div class="bar"><div class="neg" style="width:{{ (s.negative/s.count*100)|round }}%"></div></div>
</div>
<div class="card">
<h3 style="margin-bottom:10px">What customers talk about</h3>
{% for t in s.themes %}<span class="theme">{{ t }}</span>{% endfor %}
</div>
<a class="btn" href="/">← Back to reviews</a>
</div></body></html>"""
@app.route("/")
def index():
return LANDING
@app.route("/health")
def health():
return jsonify({"status": "ok", "app": "reviewpilot"})
@app.route("/analyze", methods=["POST"])
def analyze():
business = request.form.get("business", "our business").strip()
tone = request.form.get("tone", "Friendly").strip()
reviews = parse_reviews(request.form.get("reviews", ""))
csv_file = request.files.get("csv")
if csv_file and csv_file.filename:
reviews += parse_csv(csv_file.read().decode("utf-8", "ignore"))
if not reviews:
return "No reviews found — paste text or upload a CSV.", 400
results = []
for r in reviews:
r["sentiment"] = _sentiment(r["text"])
r["reply"] = _reply(r["reviewer"], r["rating"], r["text"], r["sentiment"], tone, business)
with db() as c:
c.execute("INSERT INTO reviews (reviewer, rating, text, sentiment, reply, created_at) "
"VALUES (?,?,?,?,?,?)",
(r["reviewer"], r["rating"], r["text"], r["sentiment"], r["reply"], time.time()))
results.append(r)
return render_template_string(RESULT_TMPL, reviews=results, business=business, tone=tone, count=len(results))
@app.route("/report")
def report():
with db() as c:
rows = c.execute("SELECT * FROM reviews ORDER BY created_at DESC LIMIT 500").fetchall()
reviews = [dict(r) for r in rows]
s = report_stats(reviews)
return render_template_string(REPORT_TMPL, s=s, business="your business", now=time.strftime("%B %Y"))
@app.route("/upgrade")
def upgrade():
return ("<h2>ReviewPilot Pro — $19/mo</h2><p>Full feature access, unlimited reviews and locations. "
"Pay on-chain Bitcoin.</p>")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)