One engineer, one repo, one system that answers a store's reviews
Korean sellers on Naver SmartStore live by their reviews. Each one moves search ranking and conversion, and each unanswered one quietly costs the store. A shop doing a few thousand reviews a year cannot answer them by hand — and the copy-pasted "Thank you for your purchase!" is worse than silence, because shoppers read it as neglect.
I run a Platinum-tier SmartStore myself. I built Revix because I needed it, and I built all of it: auth, tenancy, billing, the scraping layer, the job runner, the admin console. There is no boilerplate starter underneath.
The design turns on one decision
Which replies a machine may publish by itself, and which must stop and wait for a person. Everything upstream exists to make that decision reliable.
Reviews are collected from SmartStore through a browser session, merged into a per-tenant record under a lock, classified for risk and purchase stage, and drafted against a tone profile derived from the store's own writing. The draft then hits a publish gate that checks five conditions — risk level, tone configured, quota, per-store opt-in, and a daily cap. Anything that fails goes to the seller's queue, where a human reads it first. Auto-publishing is off by default and unlocked per store. It is never assumed.
What the system does
Collect. Signs into the seller's account and pulls every review with rating, product, photos and buyer history. A coverage ledger records which calendar days were provably collected in full, so a gap gets backfilled instead of becoming a permanent hole.
Learn voice. An LLM reads the store's existing replies and catalog and derives a tone profile — formality, warmth, emoji use, signature phrasing — then assigns an industry-matched persona. Drafting is hard-blocked until a tone exists, because a generic voice is worse than no reply.
Classify. Sentiment, risk, and purchase stage — first-time buyer or returning. Purchase stage is decided by the model alone and returns "unknown" when unsure, because a regex that guesses "welcome back!" at a first-time buyer is a worse failure than saying nothing.
Ask. A hybrid RAG chat over the store's own corpus — 19,731 reviews embedded in pgvector — turns "what do people complain about in the summer line?" into an answer grounded in cited reviews.
Report. A conditional daily email that goes out for a risky review, a stalled collection, a rating drop or a reply backlog — plus a weekly survival signal, so silence can never be mistaken for "nothing happened" when the truth is "the system died."
Eight subsystems, built from scratch
Web core and realtime — Flask app with 148 routes, Socket.IO progress streaming, advisory-lock helpers, an occupancy meter that alerts before the single worker saturates.
SmartStore automation — Playwright driving QR login, CAPTCHA relay, two-factor, session lifecycle, collection and reply posting against an undocumented, actively defensive target.
AI reply engine — classification, tone profiles, persona matching, pgvector RAG chat, one provider adapter so Claude and OpenAI swap by environment variable.
Billing and subscriptions — recurring billing keys, three tiers, trials, consent capture before authorization, dunning, refund-driven cancellation, quota decisions as pure functions.
Persistence and recovery — merge-under-lock writes, corrupt-file repair, a collection coverage ledger, a single source of truth for per-store settings, Alembic migrations.
Analytics and notification — weekly insight pipeline, VIP customer scoring, a free review-diagnosis lead magnet, signed unsubscribe tokens, tenant-scoped fanout.
Admin, support and compliance — operator console, live chat from sellers and guests, a versioned consent ledger, account deletion with grace period, scheduled destruction of personal data.
Workers and scheduled jobs — fourteen job modules on Redis/RQ: session warmup, auto-post, fast-reply detection, backfill, retention, embeddings, billing cron, daily and weekly reports.
Scale and stack
79,517 lines of Python — 42,418 product, 37,099 tests. 2,245 test functions carrying 4,970 assertions. 1,852 commits, 623 of them fixes, one author. 31 PostgreSQL tables, multi-tenant from the first migration. 148 HTTP routes, 14 background job modules, 50 templates, 10 CSS bundles, zero build step.
Python, Flask 3, Flask-SocketIO, SQLAlchemy and Alembic, PostgreSQL with pgvector, Redis with RQ and rq-scheduler, Playwright, Anthropic Claude and OpenAI behind one adapter, Docker on a single host, Sentry, GitHub Actions running the full test suite on every push, Jinja2 and Alpine.js on the front end.
Six failures and how they were actually found
In four of these six, the obvious diagnosis was wrong and had already been implemented. That gap — between the plausible explanation and the true one — is where most of the engineering time went.
A fallback that could post replies to the wrong merchant's store. When a seller's stored session was missing or corrupt, four read paths fell back to a global session file — in practice, "whichever seller logged in last." Cross-tenant write, and cross-tenant read of sales data. I deleted the global fallback outright; session resolution became per-tenant and fail-closed, guarded by a test asserting seller A can never resolve seller B's session.
Headless Chromium was eating the auth cookies. QR logins succeeded, then died at a timeout half a minute later. The theory held for weeks — the target detects automation and wipes the session — and mitigations shipped against it. The real cause: inside headless_shell, the NetworkService child process was crashing and restarting about 31 seconds in. That process owns the cookie store, and on a fresh profile there was nothing on disk to restore from, so cookies went from ten to zero mid-flow while the renderer looked perfectly healthy. Found by reading process ages, not logs — a child younger than its parent is a child that died and came back. The fix was one argument: run full Chromium.
A 25-second stall that was never happening. A polling loop reported the page still open and still on the old URL for 25.3 seconds. Both checks are local cache reads with no round-trip to the browser, and the loop slept with time.sleep, which blocks the event pump that would have refreshed that cache. The values were stale, not slow. An alarming "32 of 38 sessions force-closed" metric turned out to be an artifact of the same broken polling — recording that stopped a timeout increase that would have made real latency worse.
A silent lost update deleting freshly collected reviews. Atomic replacement protected against corrupt writes, not stale ones. The request thread and the reply-posting thread each wrote a whole in-memory snapshot back to disk, erasing rows the collector had appended seconds earlier. Four collector paths held the advisory lock; nineteen application writers did not. I replaced all of them with one merge-under-lock write path — acquire, re-read from disk, merge, replace atomically, never let an empty value overwrite a filled one — plus a test that fails the build if a lock-free whole-file write reappears.
Row-level security was enabled and had never once been evaluated. The policies existed, were switched on, and looked healthy. But the application connected as the table owner, and owners bypass RLS automatically. Not one policy had ever run; every tenant boundary still rested on application code remembering to filter. I built a dedicated non-bypassing role with minimal grants and session-variable tenant policies, and classified all 31 tables into four isolation categories — documenting why the users table deliberately cannot be tenant-keyed, since login happens before a tenant is known.
Webhooks that corrupted a subscription on redelivery. Idempotency was derived from a unique constraint on payment rows, so every webhook branch that creates no payment row — cancellations, billing-key deletions — re-ran its side effects on every redelivery. I added an event-level ledger keyed on the provider's webhook id, claimed before processing and released if processing crashes, so an event can neither double-apply nor vanish forever. Signature verification moved to the Standard Webhooks spec and is fail-closed in production.
What I do differently after five months of this
Distrust the first plausible cause. Four of the six above had a convincing wrong answer that someone had already shipped a fix against. A theory that explains the symptom is not a theory that predicts the next observation.
Measure the thing, not its proxy. Process ages found the cookie bug. A cached property invented a stall that did not exist. When a number is alarming, first ask whether the instrument is honest.
Every fix leaves a guard behind. A fix that only changes behavior gets undone in six weeks. A fix that adds a failing test, a database constraint, or a raising constructor stays fixed.
Prefer the database to the application. A uniqueness constraint beats a boot-time lock. A non-bypassing role beats remembering to filter. Push invariants down to the layer that cannot forget them.
Write the wrong answers down. The repo records four discarded diagnoses of one bug. Ten minutes to write; a week saved for the next person, usually me.
Fail closed on anything shared. Missing session, missing tone, missing consent — the system stops rather than guessing. Guessing is how one merchant's reply lands on another merchant's storefront.
The one I'm proudest of: catching my own inflated numbers
A sync commit had overwritten the landing page's real, database-derived statistics with placeholder marketing figures. The published claim said 12,400 replies. The truth was 1,622 — a 7.6× overstatement that sat on the sales page for months. Nobody complained. It would have kept selling.
The fix was not correcting a number. I re-derived every statistic from the production database, wrote a runbook recording which query produces which figure — including why one of them cannot come from Postgres at all, because the column that looks right also holds replies the seller wrote by hand — and then wrote a test that pins the true values and blocklists the specific inflated ones so they cannot come back.
That same runbook records something a marketer would have buried: published replies stopped growing on a particular date, because auto-publishing is opt-in and off by default. It carries an explicit instruction not to advertise the figure as "we post replies for you."
I would rather ship a smaller true number than a larger false one — and I would rather a test enforce that than my own judgment at 2am.
What this means for your project
Building this alone meant owning every layer: schema and tenancy design, retrieval and prompt architecture, browser automation against a target that does not want to be automated, subscription billing and webhook correctness, deployment, monitoring, and the incident write-up afterward.
The demo is the easy part of an AI product. What decides whether it survives is the redelivered webhook, the corrupt session, the tenant boundary nobody tested. That is the work I have been doing every day since April.
The eight subsystems, all built from scratch by one engineer.
Scale and stack, as of the write-up.
Six production failures and how each was actually found.
A production AI system that collects a store's reviews, drafts replies in its own voice, and blocks anything a human needs to see. One engineer, 53 build days.