Two background jobs turn a page's visitor transcripts into the numbers, topics and gaps an owner sees on their dashboard. Enabled in dev and production since 26 August 2026.
Every dashboard panel is a reduction over one thing: the conversations a page has had.
The work is split because the two halves have different costs and different lifetimes. A digest reads ONE transcript and is written once. A rollup reads all the digests in a time window and is rewritten whenever the window moves.
Reads one transcript, returns structured JSON: the questions the visitor asked, which went unanswered, an intent, a sentiment and topics. Written to conversation_insights.
Reads the digests inside a window, clusters topics, ranks unanswered questions by how often they were asked, and counts intents. Written to page_period_insights.
Web and mobile read the rollup rows only. No panel calls a model; by the time anyone opens a dashboard the work is already done and stored.
Tracing a number on a panel back to what produced it.
| Step | What happens |
|---|---|
chat_threads | A visitor conversation, messages stored as a JSONB array with per-message timestamps. |
DigestConversation | One model call per thread. The response schema is strict, so intent and sentiment are enum-constrained rather than free text. |
conversation_insights | One row per digested thread: questions asked, unanswered questions, intent, sentiment, topics. |
RollupWindow | One model call per page per window, over the digests inside it. |
page_period_insights | One row per page per window: headline, body, topics, gaps, intent_counts, sentiment_counts, conversation and answered-question counts. |
Answered questions are a derived figure, not a stored one: for each digest it is the questions asked minus the questions recorded as unanswered, summed across the window.
Three rolling windows are maintained per page, defined in internal/timewindow: today, last_7_days and last_30_days. A window is recomputed on a cadence and then left alone until its cooldown expires.
The window a conversation falls into is decided by chat_threads.created_at, so a panel scoped to seven days genuinely means the last seven days — not "recent".
INSIGHTS_RETENTION_DAYS prunes conversation_insights rows older than N days, and defaults to 0, meaning no pruning — deletion is opt-in so data is never silently discarded.
A value below 30 would delete rows the 30-day rollup is still summarising, so that panel would report less than it claims with nothing to indicate it. The prune now refuses to run in that case and logs at ERROR rather than clamping to a number nobody chose. The floor is tied to timewindow by a test, not a comment, because the two live in different packages.
The enum values are read directly by the UI, so adding one has consequences beyond the classifier.
The digest assigns exactly one intent per conversation from a fixed list, decided by a precedence order the prompt states explicitly — the model is told to stop at the first rule that applies, and to judge by the strongest signal rather than by whichever subject took the most turns.
| Rule | Applies when |
|---|---|
meeting_request | The visitor proposes or asks about a meeting, call, booking, appointment or consultation, or asks about availability. Outranks the subject under discussion. |
contact_request | Asks how to reach the owner, without proposing a meeting. |
hiring_or_work | Employment, a role, a contract, a commission, or engaging the owner for a project. |
pricing_or_services | What the owner offers or what it costs, with no meeting or contact request. |
background_or_credentials | History, experience, qualifications, past work, skills. |
general_enquiry | A genuine question fitting none of the above. |
other | Small talk, testing, abuse, or nothing identifiable. |
The mobile dashboard's Meeting-requests tile counts intent_counts.meeting_request over the last seven days. A demo page whose conversations never produce that intent inside that window shows a zero beside a label, which reads as a dead product rather than a new page — and no test catches it. See Demo pages.
Which vendor receives transcripts is a runtime setting in app_settings, not an environment variable, so an operator can switch it from the admin panel without a deploy. Both models are built at boot and chosen per call. The default is OpenRouter; the fallback path uses the OpenAI credentials already configured, at higher cost.
The digest and rollup use a separate provider block from the chat path on purpose. Chat is tuned for latency and voice quality, synthesis for cost per transcript, and repointing one must never silently repoint the other.
OpenRouter routes one model id across many upstream hosts — seventeen for the current digest model, quantised from fp8 down to fp4. Six identical requests seconds apart were served by three different hosts. Same published weights, different arithmetic and different structured-output implementations.
Independently, temperature 0 is not deterministic: it selects the most probable token but does not make the logits bit-identical between calls, and batch composition can change floating-point reduction order and mixture-of-experts routing. Treat per-call classification as stable in aggregate, not per conversation.
| Variable | Meaning |
|---|---|
INSIGHTS_ENABLED | Gates both jobs. Defaults to false so merging the feature could never start spending. Set per environment through a GitHub Actions variable written into the deploy heredoc; unset renders empty, which parses as false — the safe direction. |
INSIGHTS_RETENTION_DAYS | Prunes old digests. 0 disables pruning. Must exceed the widest window; see section 03. |
INSIGHTS_DIGEST_INTERVAL | How often the digest job wakes. Both jobs also run once immediately at boot. |
INSIGHTS_ROLLUP_INTERVAL | How often the rollup job wakes. A window is still subject to its own cooldown. |
INSIGHTS_DIGEST_SETTLE | A conversation is not digested until it has been quiet this long, so a live conversation is not summarised mid-flight. |
INSIGHTS_TRANSCRIPT_CHAR_BUDGET | How much of a transcript reaches the model. |
INSIGHTS_DIGEST_MAX_TOKENS | Output cap. Measuring a candidate model without this cap hides truncation. |
Per-tier daily ceilings and cooldowns are operator policy rather than deploy configuration, and live in app_settings where they can be tuned without a release.
Whether a question was answered is a judgement made by a model, so no Go test can check it. It is still measurable.
internal/harness/digest holds a small corpus of labelled transcripts: fixed input, structured output, gold labels written by a human. cmd/digest-eval scores a model against it and prints question recall, gap precision and recall, intent accuracy, and a count of unparseable responses. There is no judge model and no simulated user — just labels and arithmetic.
Because the model is not stable between calls, digest-eval takes -samples (default 3), scores each item that many times, and names any item that did not answer identically every time along with the answers it gave. Compare distributions, not single numbers, and never move a gold label because one batch disagreed with it.
Two corpus items are deliberately ambiguous and are expected to score poorly; they are the only ones that can detect a prompt change, because an item the model gets right every time measures nothing. Labels are held against the precedence list in the prompt rather than against any run's output.
How seeded demo pages produce the conversations these panels reduce over — and why a new panel means new persona content.