Skip to main content

Motivation / problem

The thin “low activity this week” email is easy to ignore. A digest earns its place in the inbox only when it tells you something you’d otherwise miss and why it matters: which decisions were promoted, which runbooks are decaying, which questions keep failing to find an answer, and what specifically needs your attention. And it has to arrive where the team already is — not only email, but Discord, Slack, Teams and inside the app.

Theory & background

A digest is a composition, not a query. DigestComposer pulls from the already-computed signal services and assembles a typed DigestPayload of sections; it never recomputes a metric. Each section is independently toggleable per user (digest_preferences.sections), and the whole digest has a frequency (weekly | monthly | off). The monthly variant is an executive roll-up (KB ROI, coverage/decision-debt trends, contributor activity). The optional narrative is produced by AiDigestNarrator using a dedicated model so it never competes with the primary chat model — and it always degrades to deterministic copy when disabled or unreachable (R14/R43), so a digest never fails because an LLM did.

Design

DigestRendererRegistry is an R23 strategy registry: every renderer FQCN is validated at boot and each renderer’s supports() predicate is mutually exclusive, so a channel resolves to exactly one renderer. Transport reuses the existing notification channel adapters (DiscordChannel / SlackChannel / TeamsChannel / EmailChannel).

Data model / contract

Knob (config/kb.phpdigest)EnvDefaultPurpose
ai_narrative_enabledKB_DIGEST_AI_NARRATIVE_ENABLEDtrueToggle the LLM narrative (both states tested, R43).
ai_providerKB_DIGEST_AI_PROVIDERopenrouterDedicated provider for the narrative task.
ai_modelKB_DIGEST_AI_MODELmeta-llama/llama-3.3-70b-instruct:freeFree model — digests are summary prose.
narrative_max_tokensKB_DIGEST_NARRATIVE_MAX_TOKENS400Token cap for the narrative.
feed_retention_daysKB_DIGEST_FEED_RETENTION_DAYS120In-app feed retention (0 disables the prune).
Surfaces (tri-surface, R44):
  • Command: digest:send {--frequency=weekly|monthly} {--tenant=} {--channel=email|discord|slack|teams} {--dry-run} {--preview} and digest:prune-feed.
  • HTTP: GET /api/admin/digest/preview (admin, RBAC-gated); GET /api/me/digest/latest and GET /api/me/digest-preferences / PUT /api/me/digest-preferences (per-user).
  • MCP: KbDigestPreviewTool (read).

Decision rationale (ADR-style)

  • Compose, don’t recompute. The composer reads the snapshot + health + insights + gaps services. This keeps a single source for each metric and makes the digest cheap to render on demand.
  • A dedicated free model for the narrative. Locking the digest to its own provider/model means a team can run an expensive primary chat model and still pay ≈$0 for digest prose, and the digest can never starve the chat model of rate budget.
  • Narrative is additive and fail-safe. ai_narrative_enabled=false (or an unreachable provider) yields a fully-formed digest with deterministic copy — the narrative is never load-bearing (R14).
  • Renderers behind an R23 mutex registry. Adding a channel is adding a renderer with a non-overlapping supports(); first-match-wins ambiguity is a boot-time error, not a silent mis-route.

Worked example

Preview the composed payload + every rendered card as JSON, without sending:
php artisan digest:send --frequency=weekly --tenant=acme --preview
Send only the Slack card for the monthly executive roll-up:
php artisan digest:send --frequency=monthly --tenant=acme --channel=slack
A user opts out of everything but the stale-review queue (valid section keys: metrics, new_docs, stale_docs, top_gaps, leaderboard):
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"frequency":"weekly","sections":["stale_docs"]}' \
  https://kb.example.com/api/me/digest-preferences

Gotchas & operations

--preview implies --dry-run: it composes and renders but never sends. Use it to inspect exactly what each channel will receive before scheduling.
  • Weekly and monthly sends are scheduled after the daily engagement snapshot so they read fresh metrics — see Scheduler & Maintenance.
  • A never-configured preference (omitted or null sections) resolves to all sections (default-in); an empty array [] means none (explicit opt-out). Don’t conflate the two — an unchecked “all” box that sends [] honestly means no sections, not silently all.
  • The in-app feed grows unbounded without digest:prune-feed; the daily prune honours feed_retention_days.
See the Engagement Suite overview for how digests fit the whole pipeline, and AI providers for configuring the dedicated narrative model.