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)
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
389
app.py
Normal file
389
app.py
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
#!/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! 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 & 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)
|
||||||
Reference in New Issue
Block a user