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

Four of the digest's fields can now be judged by a second model

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.

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

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

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_DAYSDeletes 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_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. On DeepSeek the model’s reasoning tokens count against it.
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URLDeepSeek’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_MODELBoth default to deepseek-flash, the rolling V4.1 Flash alias.
INSIGHTS_DEEPSEEK_THINKINGWhether 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.

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.

08

The decision layer, and what it changes here

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.

ModeWhat this pipeline does
off — the defaultNothing is asked and nothing changes. The digest is exactly what the rest of this page describes.
shadowThe statements are asked and every answer is stored in decision_records, but the row written to conversation_insights is the language model's.
jev / llmThe 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.