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.
Since 20 September 2026 the intent, the sentiment, the outcome and the unanswered-question list can be decided by a decision model — a model that writes nothing and only returns a probability per question — rather than by the language model that writes the summary. It is off by default, and section 08 explains what changes when it is not.
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 defaults to 365 days and bounds three tables. Setting it to 0 or less switches the whole sweep off. It used to default to 0, which meant production kept the visitor's questions word for word indefinitely while this page and the privacy policy both described a window.
It is not one action, because one of the three is read with no time bound. turn_signals and decision_records rows are DELETED: the first is read only by the live strip over 24 hours, the second by nothing member-facing. conversation_insights is REDACTED instead — its questions and unanswered_questions columns are emptied and the summary, topics, intent, sentiment and outcome are kept, because GET /v1/pages/{page_id}/conversations lists a member's whole history with no floor and deleting the row would strip a year-old conversation of its summary while the transcript beside it stayed. See repository.RedactConversationInsightQuestions.
The sweep is its own worker, jobs.InsightsRetentionPrune, started unconditionally from cmd/api/main.go. It is NOT part of the digest job and must not move back into it: the digest job runs only when INSIGHTS_ENABLED is true, which is a cap on model spend, and turn_signals and decision_records are written by the public chat path whether or not that flag is set.
A value at or below 30 would act on rows the 30-day rollup is still summarising, so that panel would report less than it claims with nothing to indicate it. The sweep refuses in that case and logs at ERROR rather than clamping to a number nobody chose, and both deploy workflows reject such a value before the container is replaced — a refusal the binary makes silently is retention switched off. The floor is tied to timewindow by a test, not a comment, because the two live in different packages.
What this window does not cover, stated because the number reaches a published policy: not chat_threads.messages, the transcripts, which hold the same words and belong to the member; and not page_period_insights, whose gaps prose is grounded in unanswered questions — one upserted row per page per window, rewritten by the rollup rather than accumulated, and swept by nothing today.
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. Every vendor with credentials is built at boot and one is chosen per call (insights_vendor: deepseek, openrouter or openai). The default is DeepSeek’s own API — the same V4.1 Flash model the OpenRouter route served, called directly for its prompt-cache pricing. Its API does not support strict JSON-schema output, so that slot runs in json_object mode with the schema carried in the prompt, and the model’s reasoning is left on (measured: off was six times faster and worse on every judgement metric). A vendor whose credentials are missing in an environment is a loud error on every job tick when selected, never a silent switch to another — who receives transcripts is a data-handling decision.
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 | Deletes old turn signals and decision records, and redacts the visitor's questions on old digests. Defaults to 365; 0 switches it off. Must exceed the widest window; see section 03. Enforced by its own ungated worker, so this is NOT gated by INSIGHTS_ENABLED. |
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. On DeepSeek the model’s reasoning tokens count against it. |
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL | DeepSeek’s own API — the default vendor. No key means the slot is not built and selecting it is a loud error. |
INSIGHTS_DEEPSEEK_DIGEST_MODEL, INSIGHTS_DEEPSEEK_ROLLUP_MODEL | Both default to deepseek-flash, the rolling V4.1 Flash alias. |
INSIGHTS_DEEPSEEK_THINKING | Whether the model reasons before answering. Default true; measured with make digest-eval. |
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.
The digest asks a language model for two different things at once: words, and judgements. Only the second half is affected.
The summary sentence and the topic list are writing, and the language model keeps them. The other four fields are closed-list judgements — intent, sentiment, outcome and unanswered_questions — and those are the ones that produced the failures this pipeline has had to defend against, most often a question the ambassador answered with a caveat being filed as a gap the owner cannot close.
When the decision_vendor setting is switched on, the digest still runs exactly as described above and then asks a decision model a statement per field: five statements for the intent (did the visitor ask to meet, to make contact, about hiring, about pricing, about the owner's background), one multiple choice for the tone, and one statement per question the visitor asked (“did the ambassador supply real information for this one?”). Go code turns the probabilities into the four stored values — including the precedence rule that a request to book outranks whatever subject took up the most turns, which is now applied in code rather than asked of a model.
| Mode | What this pipeline does |
|---|---|
off — the default | Nothing is asked and nothing changes. The digest is exactly what the rest of this page describes. |
shadow | The statements are asked and every answer is stored in decision_records, but the row written to conversation_insights is the language model's. |
jev / llm | The four fields stored are the decision model's. conversation_insights.model becomes <digest model>+jev or +llm, naming the arm that answered. |
The same corpus and the same make digest-eval command measure it: pass -decider jev or -decider llm to score what production would store with the layer acting, and -decider none for the language model alone. On the 15-item corpus the decision model scored level with the language model, with every difference inside the run-to-run spread, which is the baseline any later change has to beat.
The second model in full: what it is, the three places it is asked, every threshold an operator can set, and what was measured before it shipped.
How seeded demo pages produce the conversations these panels reduce over — and why a new panel means new persona content.