← Platform Docs
Architecture

Conversation insights

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.

01

Two layers, not one

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.

Per conversation

Digest

Reads one transcript, returns structured JSON: the questions the visitor asked, which went unanswered, an intent, a sentiment and topics. Written to conversation_insights.

Per window

Rollup

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.

Read path

Dashboard

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.

02

Where each value comes from

Tracing a number on a panel back to what produced it.

StepWhat happens
chat_threadsA visitor conversation, messages stored as a JSONB array with per-message timestamps.
DigestConversationOne model call per thread. The response schema is strict, so intent and sentiment are enum-constrained rather than free text.
conversation_insightsOne row per digested thread: questions asked, unanswered questions, intent, sentiment, topics.
RollupWindowOne model call per page per window, over the digests inside it.
page_period_insightsOne 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.

03

Windows, and why the widest one bounds retention

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".

Retention must exceed the widest window

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.

04

Intent is a product surface

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.

RuleApplies when
meeting_requestThe visitor proposes or asks about a meeting, call, booking, appointment or consultation, or asks about availability. Outranks the subject under discussion.
contact_requestAsks how to reach the owner, without proposing a meeting.
hiring_or_workEmployment, a role, a contract, a commission, or engaging the owner for a project.
pricing_or_servicesWhat the owner offers or what it costs, with no meeting or contact request.
background_or_credentialsHistory, experience, qualifications, past work, skills.
general_enquiryA genuine question fitting none of the above.
otherSmall talk, testing, abuse, or nothing identifiable.
A new intent value needs demo data

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.

05

Vendor, models and cost

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.

A model id does not identify what answered

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.

06

Configuration

VariableMeaning
INSIGHTS_ENABLEDGates 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_DAYSPrunes old digests. 0 disables pruning. Must exceed the widest window; see section 03.
INSIGHTS_DIGEST_INTERVALHow often the digest job wakes. Both jobs also run once immediately at boot.
INSIGHTS_ROLLUP_INTERVALHow often the rollup job wakes. A window is still subject to its own cooldown.
INSIGHTS_DIGEST_SETTLEA conversation is not digested until it has been quiet this long, so a live conversation is not summarised mid-flight.
INSIGHTS_TRANSCRIPT_CHAR_BUDGETHow much of a transcript reaches the model.
INSIGHTS_DIGEST_MAX_TOKENSOutput 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.

07

The eval harness

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.

One run is not a measurement

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.