AI Summaries
A morning brief about one site — what kind of day it was, what to do about it, what is not worth worrying about, and which two or three visits are worth your own minutes. It appears on the AI digest widget of the site's home dashboard, on the digest page, and in the nightly email.
A brief has four parts:
- The situation — two or three sentences about what kind of day it was, in context. A 69% fall in human sessions is a catastrophe or a bot farm leaving, and this is the part that says which.
- Do today — up to five actions, ordered by money. Each one carries why it matters, what to actually do, an honest estimate of what it costs you (a few minutes · an hour or two · developer work), and links to the rows it was written from.
- Not news — what looks alarming and is not. A browser message we cannot see inside, a fault present on a third of your visits, a crawler leaving. This is the half that saves an afternoon.
- Worth opening — at most three recorded visits or chats, each with one line saying why.
The numbers are not written by the model
This is the part worth understanding before you rely on it.
Every figure in a brief is computed first, deterministically, by SQL over the same rollups that feed the rest of the dashboard. That fact sheet is what the model receives, and the screen renders the sheet, not the model's arithmetic.
The distinction matters because prose can be wrong and a number cannot: if the model writes "sales fell sharply" over a day that was flat, you have a bad sentence over correct figures. It cannot invent a revenue number, because it never computes one.
A link in a brief points at a real row, and that is a property of the data rather than a habit. Every row the model may cite carries an opaque name — r1, k3, e4 — that means nothing outside one night's sheet, and the platform keeps the map from those names to the rows. A citation the map does not hold is dropped before anything renders it, so an invented link cannot exist.
What is sent to the model
Only aggregates:
- Pageviews, sessions, unique visitors, bounce rate, average time on page
- Error, click, rage-click and dead-click counts
- The top five page paths (never full URLs, so no query strings)
- The top three device types and countries
- Funnel step labels, their session counts and the drop-off at each step
- Money: abandoned carts and their value, carts blocked by errors, orders, currency
- Revenue, order count, average order value and the abandonment rate
- The top three error messages, with how many sessions each has touched
- Issues first seen in the period, and closed issues that came back — with the release each is attached to
- Error spikes, error counts by severity, and the standing count of open issues
- Recorded sessions: how many, how long on average, how many hit an error or rage-clicked
- The pages with the most rage and dead clicks, and average scroll depth
- The highest-scoring visits, described only as score, duration, error and rage counts, device and country
- Uptime: probe counts, the success rate and any incidents, with duration and status code
- Survey response counts, NPS scores and rating averages — never the words anyone typed
- The same period against the trailing 7 and 28 closed days, plus the pages that rose or fell most
And, since the brief, the EVIDENCE an action is written from — each capped, so a busy shop sends the same amount as a quiet one:
- The five highest-scoring recorded visits, with the account already written about each (see below)
- The five longest chats of the day, as the six answers a per-chat reading produces — never the transcript
- The day's eight largest abandoned carts: value, basket, country, which fault was on the checkout, and whether there is anybody to ring
- A verdict on every error the sheet names — whether it is the shop's to fix at all, or a browser message nobody can see inside, browser weather, our own collector's bug, or a fault present on a third of the site's visits
- Products a trailing week's visitors looked at and nobody bought; searches the shop's own page said found nothing
- Page speed (LCP, CLS, INP, TTFB) for the busiest templates, refused rather than guessed below 20 samples
- Which population moved — human or automated — so a crawler leaving is never reported as a collapse
No email address, phone number, name, IP address, visitor id or session id is ever included. The error messages and uptime failure reasons — the only free text that goes — are passed through the same redaction the rest of the platform uses (addresses, long digit runs, tokens) and truncated.
Survey text answers are counted, never quoted: they are a visitor's own words, and AI_BASE_URL may point at a third party. Read them on the survey results page.
Because site-level summaries are aggregates, GDPR erasure does not touch them: there is nothing in one that belongs to a person. Per-recording summaries (below) are the opposite case and are handled the opposite way.
Staleness, and why nothing regenerates itself
Each brief stores a hash of the fact sheet it was written from, the version of the prompt, and the language it was written in. When any of the three changes, the widget shows a badge and a button — it does not rewrite itself.
There is no TTL and no automatic regeneration, on purpose. A narrative that silently changed under someone who had just read it, or that spent model time every hour on a day nobody looked at, would be worse than a stale one that says it is stale.
A model that answers prose instead of the JSON it was asked for produces a failed row with a "Try again" button — never a garbled summary.
Configuration
Off unless both variables are set. When off, the widget explains that in one sentence rather than erroring; this is a supported configuration, not a broken one.
| Variable | Meaning |
|---|---|
AI_BASE_URL | Origin of an OpenAI-compatible server. Without /v1 — the client appends /v1/chat/completions itself. |
AI_MODEL | Model name, e.g. llama3.1:8b |
AI_API_KEY | Only if the endpoint requires one. Local Ollama does not. |
AI_TIMEOUT_MS | Default 180000. An 8B model on CPU legitimately takes minutes. |
Any OpenAI-compatible endpoint works — Ollama, llama.cpp, vLLM, or a hosted provider speaking the same dialect.
:::tip The /v1 trap
Almost every OpenAI-compatible example on the internet ends in /v1. Setting AI_BASE_URL=http://localhost:11434/v1 produces requests to /v1/v1/chat/completions, which 404 — and a 404 here looks exactly like an unreachable model. The value is stripped defensively and the startup config check warns about it, but the setting should read the way it behaves.
:::
Where to run the model
An external endpoint is recommended for a production deployment: a separate GPU box running vLLM, or a hosted OpenAI-compatible provider.
A model resident on the application server competes with everything else that runs at night — the 02:15 rollups, the 02:45 re-scoring, the 03:30 backup and the 04:00 retention sweep — and with the headless Chromium that renders heatmap backgrounds. A model that gets swapped out blows past AI_TIMEOUT_MS, and every summary becomes a failed row, which reads like a model-quality problem rather than a memory one.
If you do want the model on the same host, install Ollama on the host itself and point the backend at it through host.docker.internal, which the production compose file already resolves:
ollama pull llama3.1:8b
Then set AI_BASE_URL=http://host.docker.internal:11434 and AI_MODEL=llama3.1:8b. Only on a machine with at least 16 GB, and pull the model before the first nightly run — an unpulled model answers 404 and every site's summary lands as failed. The compose file used to ship an ollama service behind a profile; it was removed once the hosted deployment moved to a hosted model, because a small model on the application server timed out more often than it answered.
When summaries are written
- On demand, from the Generate button — on the home dashboard's AI digest widget, or on the AI digest page (
Analytics → AI digest), which lists every stored digest, newest first. - Nightly at 04:30, after the per-visit pass (03:50) and the per-chat pass (04:05) have written what the brief cites, and after the rollups have closed the previous day, for every site that saw any activity — a pageview, a session, an error, a lead or a click. Weekly summaries are added on Mondays.
- Also for a site that saw nothing at all, if that site has a digest recipient configured. A day with no activity is news for anybody who asked to be told about their shop each morning.
The nightly pass is idempotent: a site whose facts have not changed since its last summary is skipped without calling the model.
A quiet day says why it was quiet
A period with nothing in it is not narrated by the model at all — there is no prose to write over zeros, and asking for some is asking for invention. It is written in code, and it names the reason:
| Reason | What it means |
|---|---|
| Consent required | The site requires consent before tracking and no visitor has ever granted it — so nothing can be collected. |
| Never collected | No data has ever reached the platform from this site: the snippet or plugin is not installed, or cannot reach us. |
| Collection stopped | Data used to arrive and no longer does — a removed snippet, a deactivated plugin, or a privacy setting that changed. |
| Quiet | The site is instrumented and reachable. There were simply no visitors. |
The same verdict drives a banner across the dashboard, so an empty set of charts explains itself where you are looking rather than only in tomorrow's mail. The first row of that table is the reason it exists: a site whose consent gate is on with no banner to open it looks completely healthy from every other angle, and collects nothing.
Per-recording summaries
High-interest session recordings get a narrative of their own: a nightly pass (03:50, before
the site digests) writes a short account of each recording scored 80 or higher — the
"High interest" band the recordings list already shows — capped at 5 recordings per site per
night, highest scores first. The summary appears on the recording's detail page. A recording
below the threshold shows nothing at all: absence is the ordinary state, not an error.
AI_RECORDING_SCORE_MIN and AI_RECORDING_NIGHTLY_MAX move the threshold and the cap.
The fact sheet sent to the model describes the visit, never the person: pages visited, clicks, scroll depth, rage and dead clicks, error messages (redacted), and the checkout outcome as status and totals. It carries no visitor identifier, no session identifier, no contact fields, and none of your team's notes or tags.
Unlike site-level summaries, a per-visit narrative is personal data. It is deleted with the visitor when a GDPR erasure runs, exported with them when they ask for their data, and it never outlives the footage — the 30-day recording retention removes the summary with the recording it describes.
Per-chat summaries
A chat from your storefront widget is read once a night too (04:05), and for the same reason
the recordings pass moved ahead of the digest: the morning brief cites what people asked
about, so the reading has to exist before the brief is written. A chat of two messages or
more qualifies, longest first, capped per site per night (AI_CHAT_NIGHTLY_MAX).
What comes back is not prose. It is six answers: what the visitor wanted, how it ended (answered · unanswered · handed off · abandoned), what they asked and did not get, whether they were browsing or ready to buy or asking as a business, which products were named, and one line for you. That is what the digest reads — never the transcript itself.
The fact sheet sent to the model carries the exchange with contact details masked a second time: email addresses and telephone numbers survive in your dashboard because in a chat they are the contact detail you read it for, and they are removed on the way to the model. The visitor's name, where we hold one, is masked by value — a name is the one contact shape no pattern can find. The agent's first name is kept: your own staff are not who that floor protects. No visitor identifier, no session identifier and no lead id ride on it.
Like a per-visit narrative, a per-chat reading is personal data: it is deleted with the visitor, exported with them, and swept with the conversation it describes.
The language it is written in
Site setting, in Settings → Notifications: English or Ukrainian, ai_digest_language, English by default. It is separate from the dashboard's own language on purpose — a digest is read in a mailbox, often by somebody who never opens the dashboard, and a shop with a Ukrainian owner and an English-speaking developer has one of each.
Numbers, currency codes, page paths, product names and error messages are the same in both: they are what the sheet says, and the model is told to leave them alone.
The findings underneath, and why you no longer read them as a list
The platform derives typed findings from every fact sheet — carts blocked by errors, an orders drop, a new issue, friction on one element — each with a floor under it. Until this brief existed they were printed as a list of cards above the prose, and the prose restated them: one morning said twice, in two voices and two orders.
They are now an input. The model reads them as ranked candidates, decides what today actually calls for, and may raise something the list missed or stay silent about something on it — provided the sheet says so. Their floors still do the work they always did: a finding that cries about nothing never reaches the model either.
They are still readable directly, and nothing about them changed: GET /api/v1/sites/:id/insights
serves them, and an AI client connected through the Model Context Protocol reads the same list
with get_insights.
When a finding is worth your morning
A finding is not raised because something happened once. Each kind carries a floor, and the one about money is the strictest, because it is the one that claims a sale was lost:
- Carts blocked by errors is raised when there is money at stake, or when at least 3 carts were affected. One abandoned cart whose visit met one fault is not a pattern, and a finding that cries about nothing costs you the one that matters.
- It is marked critical only when the value at risk reaches 500 in your shop's own currency. Below that it is a warning — still on the list, still ranked above traffic.
- What counts as a fault that stopped a customer is narrower than the Issues list: a browser notice with no fix in shop code — a layout-loop warning, a declined permission, an autoplay refusal — never counts, whatever its volume, and neither does a fault in our own collector.
- The finding names the fault it blames. Where the loudest message of the day is one of those
browser notices — or one the browser replaced with
Script error.and no stack, whose only remedy is an attribute on somebody else's script tag — it says nothing rather than blaming the wrong thing.
A finding has a lifetime
Findings are derived fresh from each morning's fact sheet. Without a memory, the same rage on the same button would arrive as news every morning it kept happening. So each one carries an identity, and the platform keeps one lifecycle record per finding:
- A finding seen for the first time is marked new. From the second morning on it carries when it was first seen and how many mornings it has appeared — the same finding ages instead of being reborn.
- A finding that stops appearing for 3 consecutive closed days with traffic is marked resolved. It stays readable for 7 days and then moves to history. The digest no longer announces it: a list of things that stopped happening is not something anybody acts on over coffee, and it was the first thing every reader scrolled past.
- The traffic condition is deliberate: on days when nothing was collected, silence proves nothing. A site whose collection stopped keeps its findings open rather than celebrating them as fixed.
- Site-wide findings (a traffic drop, an orders drop) re-arm after 7 quiet days: the next occurrence is reported as a new episode, not day forty of an old one.
Resolved records are kept for 90 days and then deleted; open ones are kept as long as they stay open. The lifecycle stores what the finding was about — an element selector, a page path, a channel name — and never a visitor or session id.
Email digest
The nightly pass can mail each digest as it is written. Set the recipients in
Settings → Notifications → AI digest (the ai_digest_email setting — comma-separated
addresses; leaving it empty switches the mail off, which is the default).
Three rules keep it from becoming a spam machine, and they are pinned by verify:ai:
- A mail is sent only when the nightly pass writes a new summary. A night skipped for unchanged facts sends nothing — the mail already sent is the mail that would be sent.
- A failed generation sends nothing; the failure lives on the dashboard, not in an inbox.
- The Generate button never mails — whoever pressed it is already looking at the result.
The mail renders the stored narrative and then the sheet's own figures — traffic, commerce, errors, sessions and friction, uptime and surveys, each section printed only when it has something to say. Nothing is recomputed at send time. Mondays can bring two mails — the daily and the weekly digest are both new that morning. Delivery uses the platform's SMTP settings; the Send test button next to the recipients field proves the route with a clearly-labelled test mail, never a fake digest.
Verifying a setup
pnpm --filter backend build && pnpm verify:ai
Assertions covering the week arithmetic, the hashing, the redaction, every section of the fact sheet, the quiet-day diagnosis, the mail's rendering, the failure modes and the nightly batch's idempotency — run against scripted models, so it needs no endpoint of its own.