Reasons
Every slot carries a reason key and a sentence in the user's language. No reason, no recommendation.
Every item Patterna returns comes with a reason the user could read. The reason is not decoration. It is a promise that the system knows why it chose the item, and it is the first thing a user, an editor or a regulator asks.
How a reason is made#
- Several generators score the candidates (trending, curated, item-to-item, session items, content affinity, persona model, user model, surface-specific, and the silent
time_fitandformat_fit). - The ranker adds the scores up. For each slot, the generator with the largest contribution is the winner.
time_fit,format_fitand the region boost never win: they move scores, but nothing they could say would be a reason a person accepts. - The winner maps to a reason key, for example
item_to_itemwith an anchor →similar_to_read, but only when the reason is true (next section). Otherwise the key isrecommended. - Serve looks up the sentence for that reason key: your override from the flow's
reasonsmap if there is one, otherwise Patterna's default. It returns the sentence in every locale it has.
A slot in the response looks like this:
{
"item_id": "doc_4821",
"kind": "document",
"position": 0,
"reason_key": "similar_to_read",
"reason": {
"nl": "Omdat je dit net las",
"en": "Because you just read this"
},
"generator": "item_to_item",
"score": 0.82
}Positions start at 0. Your app shows reason[locale] as one line under the title. If the user's locale is missing from the map, fall back to your default locale.
The truthfulness rule#
A slot names its winner only when what the sentence says is true for that item:
- The winner's own signal is positive. The raw similarity, affinity or model score for the item must be above zero. An item can win a slot on a z-score while its raw signal is zero or negative (it was merely the least bad); then there is nothing true to say about it.
- "Trending" needs this surface's own log. Before the surface has one, trending runs on a tenant-wide popularity prior, and the slot says
popular, not "widely read this week". - "People like you" needs a real persona. A user scored with the cold-start persona gets
recommended. - "New" means new.
surface_specificsaysnewonly for an item published in the last 30 days.
When the rule fails, the slot carries reason_key: "recommended" with "Aanbevolen" / "Recommended" and nothing more. That is a correct answer, not an error: show it like any other reason, or hide the reason line for this key if your design prefers. Expect more recommended slots on a new surface and for new users; their share falls as the log and the personas grow.
Reason keys and defaults#
| Winner | Reason key | Default (nl / en) |
|---|---|---|
item_to_item with an anchor | similar_to_read | Omdat je dit net las / Because you just read this |
item_to_item without an anchor | similar | Lijkt op wat je leest / Similar to what you read |
content_affinity on declared goals | matches_goals | Past bij je doelen / Matches your goals |
content_affinity on behaviour | more_about_tag | Meer over dit onderwerp / More about this topic (a template such as "Meer over {tag}" names the tag) |
item_to_item with an anchor of another kind that pairs: a recipe or a workout with a product, a community with a document | pairs_with | Past bij dit recept / Pairs with this recipe; Past bij deze workout / Pairs with this workout; Gelezen in deze community / Read in this community |
item_to_item with any other cross-kind anchor | more_about_tag | as above |
session_items | similar | Lijkt op wat je leest / Similar to what you read |
persona_bayes, user_bayes, user_to_user | people_like_you | Populair bij leden zoals jij / Popular with members like you |
trending on this surface's own log | trending | Deze week veel gelezen / Widely read this week |
trending on the tenant-wide prior | popular | Populair / Popular |
curated | editors_pick | Gekozen door de redactie / Chosen by the editors |
surface_specific, item under 30 days | new | Nieuw / New |
any winner whose signal is not positive; a cold persona; an old item under surface_specific | recommended | Aanbevolen / Recommended |
The editorial list (the control arm, and the fallback) carries editors_pick for a curated list, popular for the 7-day popular list and new for the newest list. The full table is in Reason keys.
trending is the smoothed 7-day tap rate of each item on this surface, all locales together (the request's locale decides which items are candidates). It is not computed per persona or segment.
pairs_with is said only where it is true: a product under a recipe (a document in a collection whose name contains recipe, recept, maaltijd or meal), a product under a workout (workout, training, oefening, exercise), or a document under a community. Each pairing has its own default text. Any other cross-kind anchor, such as a product under an ordinary article, says more_about_tag. context.anchor_item_id is looked up in the whole catalog, whatever its kind.
Overrides#
Overrides live in the flow config under reasons, keyed by reason key or by generator kind, then by locale:
{
"reasons": {
"similar_to_read": { "nl": "Omdat je dit net las", "en": "Because you just read this" },
"trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" },
"content_affinity": { "nl": "Meer over {tag}", "en": "More about {tag}" }
}
}Precedence. For each slot serve takes the reason-key template if there is one, else the template of the winning generator, else the default.
An override replaces the whole locale map for that key. If you override trending with only nl, the en default is gone for that key, so always give every locale you serve.
Because overrides are part of the flow, changing a reason creates a new flow version. You edit them per locale in the flow editor. The change is versioned, audited and can be rolled back like any flow change.
Placeholders#
{anchor_title}: the anchor item's title in the request locale.{tag}: the winning tag's label per locale, from the taxonomy. Phase tags and health claims are never named.{segment}: the user's persona label (taxonomy kindsegment).
When a placeholder cannot be filled for a locale, that locale gets the default text instead, so a user never sees a literal {tag}.
Validation#
POST /surfaces/{key}/flows refuses an unknown reason key or generator key, an unknown locale or an unknown placeholder with 400 invalid_reason_template, so a typo never reaches a user.
Drafts from the jobs#
The reasons job drafts missing sentences with the LLM and saves them as a new, inactive flow version with a note that starts with REVIEW NEEDED, plus an audit entry. It never overwrites a text and never activates anything.
The job keys its drafts by generator kind. Serve reads generator-keyed templates, so the drafts work as they are.
Locales#
Reasons come back as a map per locale, today nl and en. The app picks the sentence to show. The request's context.locale filters items; it does not choose the reason.
Rules for good reasons#
- True. The sentence must describe what the generator actually used. "Because you read X" only from item-to-item with an anchor; "Pairs with this recipe" only under a recipe. The engine enforces the rule above for its own keys; your overrides must keep to it too.
- Short. One line under a title.
- No health values. A reason never states a measurement, a symptom or a cycle phase.
- Plain. No superlatives, no "just for you".
- Approved. Have an editor sign off every sentence per locale before activation.
You may choose not to render the reason on a placement. Patterna still returns and logs it.