Code Reference, Recsys package¶
Auto-generated from source by mkdocstrings. Signatures,
type annotations, fields, and docstrings are rendered directly from
ai_engine.recsys.
Contracts¶
Models¶
models
¶
Tag
pydantic-model
¶
Bases: BaseModel
One expert tag on a piece of content. facet is a taxonomy dimension
(e.g. 'theme_what', 'person_who.age_group'); label the value.
Fields:
-
facet(str) -
label(str) -
weight(float)
Content
pydantic-model
¶
Bases: BaseModel
Normalized item. Supersedes the loose Qdrant payload dicts.
This is the ONE place raw payload shape is interpreted (see
qdrant_store._payload_to_content): consumers — endpoints and the dashboard —
read these fields and never parse payloads themselves (docs/debt-payload-scatter.md D1).
Fields:
-
id(str) -
content_type(ContentType) -
title(str) -
text(str) -
tags(list[Tag]) -
word_count(int) -
has_image(bool) -
lat(Optional[float]) -
lon(Optional[float]) -
image_url(Optional[str]) -
public_url(Optional[str]) -
years(list[int])
InteractionEvent
pydantic-model
¶
Bases: BaseModel
One thing a visitor did, in a source-agnostic shape.
Every event source (RudderStack, PostHog, Postgres) is normalized into this single
model (see ai_engine.recsys.adapters.rudderstack.normalize_events), so the rest
of the engine never sees a raw payload. The event string says what happened, and
different events fill different fields (the rest stay None/empty):
- A view (
CONTENT_VIEW_ENDED) fillscontent_id,dwell_seconds,end_reason,request_id, andimpressions. - A survey / identify (
SURVEY_SUBMITTED,IDENTIFY) fillssurvey_answers. - A search / lookup (
CONTENT_LOOKUP) fillsquery_textand maybeclicked_id.
build_user_signals folds a visitor's list of these into their user model.
Fields:
-
user_id(str) -
event(str) -
ts(datetime) -
session_id(Optional[str]) -
request_id(Optional[str]) -
content_id(Optional[str]) -
dwell_seconds(Optional[float]) -
end_reason(Optional[EndReason]) -
query_text(Optional[str]) -
clicked_id(Optional[str]) -
impressions(list[str]) -
survey_answers(dict) -
raw(dict)
event
pydantic-field
¶
What happened, e.g. CONTENT_VIEW_ENDED, SURVEY_SUBMITTED, IDENTIFY, CONTENT_LOOKUP.
ts
pydantic-field
¶
UTC timestamp of the interaction (used for recency decay + ordering).
session_id
pydantic-field
¶
Browser/app session, for sequence grouping.
request_id
pydantic-field
¶
The rec-response id echoed back on a resulting view, joins a reward to the exact impression (its served feature vector) for bandit training.
content_id
pydantic-field
¶
The item this event is about (source prefix like 'content_1234' stripped to '1234').
dwell_seconds
pydantic-field
¶
Seconds spent on the content (explicit, or computed from a start/end pair).
end_reason
pydantic-field
¶
end_reason: Optional[EndReason] = None
How a view ended (next_button/link/close_button/abandon) → a completion score.
query_text
pydantic-field
¶
Search or lookup text, for CONTENT_LOOKUP events.
impressions
pydantic-field
¶
Other item ids shown alongside but not engaged → treated as soft negatives.
survey_answers
pydantic-field
¶
question_id → answer (str / list for multi-select / float rating). Presurvey + personalization.
UserSignals
pydantic-model
¶
Bases: BaseModel
The user model: everything the recommender needs about one visitor.
Built by ai_engine.recsys.signals.signal_builder.build_user_signals from the
visitor's events (plus survey demographics), and read at serve time by the
Recommender. Two shapes of signal live here: tag signals (tag_affinity /
tag_aversion, matched by score_tag) and vector signals (taste_vector /
recency_vector, matched by the embedding scorers). A visitor with no positive
engagement yet is "cold" (see is_cold), and recommendations lean on their survey
tags until browsing warms the model up.
Fields:
-
user_id(str) -
positives(dict[str, float]) -
negatives(dict[str, float]) -
viewed(list[str]) -
recent_views(list[str]) -
tag_affinity(dict[str, float]) -
tag_aversion(dict[str, float]) -
taste_vector(Optional[Vector]) -
recency_vector(Optional[Vector]) -
behavior(dict) -
demographics(dict)
positives
pydantic-field
¶
content_id -> recency-decayed positive strength, for items the visitor engaged with well. Seeds the taste vector and tag affinity.
negatives
pydantic-field
¶
content_id -> recency-decayed penalty, from disliked views and shown-but-ignored impressions (soft negatives).
viewed
pydantic-field
¶
Every content_id the visitor has seen (any outcome). Used to exclude already-seen items from recommendations.
recent_views
pydantic-field
¶
content_ids ordered most-recent-first, giving the model sequence awareness (the recency signal).
tag_affinity
pydantic-field
¶
'facet:label' -> [0,1] interest weight, blended from survey answers and engaged content. The main signal score_tag matches against content tags.
tag_aversion
pydantic-field
¶
'facet:label' -> [0,1] penalty weight, from the themes of content the visitor disliked. Applied as a negative in fusion.
taste_vector
pydantic-field
¶
L2-normalized centroid of liked items' embeddings (the whole-history semantic taste). None until the visitor has a positive.
recency_vector
pydantic-field
¶
Embedding of the most-recent viewed item, powering the 'more like what you just read' signal.
behavior
pydantic-field
¶
Engagement summary stats (n_views, completion_rate, depth, ...). Used for persona explanations, not for scoring.
demographics
pydantic-field
¶
Raw survey demographics, stored for inspection. The affinity they seed lives in tag_affinity. E.g. {"age": 60, "gender": "female", "nationality": "france", "province": "Drenthe", "personal_connection": "descendant"}.
is_cold
property
¶
True until the visitor has at least one positively-engaged item. Cold visitors get survey-led / diverse recommendations; warm ones get taste-vector-led ones.
Recommendation
pydantic-model
¶
Bases: BaseModel
The served result: the ranked items, the strategy that produced them
(warm / cold), and a diagnostics dict (pool size, generators used, filter,
ranking mode, distractor placement) for the inspector and explanations.
Fields:
-
user_id(str) -
items(list[ScoredCandidate]) -
strategy(str) -
diagnostics(dict)
VisitorType
pydantic-model
¶
Bases: BaseModel
Falk (2009) visit-identity classification of the visitor.
Fields:
-
type(str) -
confidence(float) -
rationale(str) -
scores(dict[str, float])
PersonaExplanation
pydantic-model
¶
Bases: BaseModel
Structured, evidence-backed explanation derived purely from UserSignals +
content taxonomy. Deterministic; summary is the optional verbalized prose.
Fields:
-
user_id(str) -
is_cold(bool) -
interests(list[Interest]) -
aversions(list[Interest]) -
engagement_style(str) -
experience_preference(str) -
visitor_type(Optional[VisitorType]) -
trajectory(list[str]) -
demographics(dict) -
behavior(dict) -
summary(Optional[str])
Enums¶
enums
¶
EndReason
¶
Bases: str, Enum
How a content view ended (RudderStack CONTENT_VIEW_ENDED.details.reason).
Config¶
config
¶
EngagementWeights
pydantic-model
¶
Bases: BaseModel
How much each behavioral signal contributes to a viewed item's engagement strength.
The four weights are combined into a single strength in [-1, 1] per item, which then decides whether the view counts as positive, neutral, or negative.
Fields:
-
dwell(float) -
completion(float) -
revisit(float) -
survey(float)
dwell
pydantic-field
¶
Weight of how long the visitor stayed, relative to the item's estimated reading time.
completion
pydantic-field
¶
Weight of how the view ended (finishing the item vs abandoning it).
revisit
pydantic-field
¶
Weight of coming back to the same item more than once.
survey
pydantic-field
¶
Weight of an explicit rating (e.g. a 1 to 5 star), when the visitor gives one.
FusionWeights
pydantic-model
¶
Bases: BaseModel
How much each scorer contributes to the final fused score. Each scorer returns [0,1].
Tag-first policy: tag matching is the dominant signal so recommendations track the
visitor's stated (survey) and browsed interests legibly. The semantic embedding
signal is kept below tag: it adds coverage for cold/unseen content, it doesn't lead.
Fields:
tag
pydantic-field
¶
Dominant signal: overlap between the visitor's tag interests and the content's tags (survey + browsing).
semantic
pydantic-field
¶
Similarity to the visitor's overall taste vector (the centroid of everything they liked).
recency
pydantic-field
¶
Similarity to the item the visitor viewed most recently.
aversion
pydantic-field
¶
Overlap with disliked themes. Negative, so it pushes matching content down.
geo
pydantic-field
¶
Physical proximity to the request's location. Scored only when a location is given.
RecConfig
pydantic-model
¶
Bases: BaseModel
All tunable settings for the recommender in one typed place.
Passing a config is how tests and tenants pin behavior. Every value has a sensible
default, so RecConfig() is a complete, working configuration; override individual
fields (or via RECSYS_* environment variables) to change behavior.
Fields:
-
engagement(EngagementWeights) -
fusion(FusionWeights) -
reading_speed_wps(float) -
img_extra_time(float) -
dwell_cap_ratio(float) -
positive_threshold(float) -
negative_threshold(float) -
half_life_days(float) -
soft_negative_weight(float) -
pool_per_generator(int) -
final_limit(int) -
mmr_lambda(float) -
geo_scale_m(float) -
geo_radius_m(float) -
filter_reshow_when_exhausted(bool) -
distractor_enabled(bool) -
distractor_strategy(str) -
distractor_probability(float) -
distractor_slots(list[int]) -
cold_start_min_positives(int) -
tag_engagement_trust_k(float) -
tag_match_topk(int) -
ranking_mode(str) -
bandit_alpha(float) -
bandit_ridge(float) -
bandit_explore(bool) -
bandit_online(bool)
engagement
pydantic-field
¶
engagement: EngagementWeights
Weights for turning a raw view into an engagement strength.
fusion
pydantic-field
¶
fusion: FusionWeights
Weights for combining the per-scorer signals into the final ranking score.
reading_speed_wps
pydantic-field
¶
Assumed reading speed in words per second (~250 wpm), used to estimate how long an item should take to read.
img_extra_time
pydantic-field
¶
Extra seconds added to the reading-time estimate for an item that has an image.
dwell_cap_ratio
pydantic-field
¶
Caps dwell / estimated-reading-time at this ratio before normalizing, so one very long view can't dominate.
positive_threshold
pydantic-field
¶
Engagement strength at or above this counts as a positive (liked) view.
negative_threshold
pydantic-field
¶
Engagement strength at or below this counts as a negative (disliked) view; between the two thresholds is neutral.
half_life_days
pydantic-field
¶
Half-life in days for time decay: a signal's weight halves every this many days, so recent behavior counts for more.
soft_negative_weight
pydantic-field
¶
Penalty for an item that was shown to the visitor but never engaged (a 'soft negative').
pool_per_generator
pydantic-field
¶
How many candidate items each generator (semantic, tag, geo, ...) contributes before ranking.
final_limit
pydantic-field
¶
Number of items returned in a recommendation list.
mmr_lambda
pydantic-field
¶
Relevance vs diversity trade-off in MMR reranking: 1.0 is pure relevance, 0.0 is pure diversity.
geo_scale_m
pydantic-field
¶
Distance scale in metres for geo proximity: score = exp(-distance / scale), roughly a camp-sized falloff.
geo_radius_m
pydantic-field
¶
Default radius in metres used when a geo filter is requested.
filter_reshow_when_exhausted
pydantic-field
¶
When a location filter runs out of unseen content, re-show its already-seen items instead of returning empty (never leaks outside the filter).
distractor_enabled
pydantic-field
¶
Whether to inject one deliberately off-profile item for novelty / exploration.
distractor_strategy
pydantic-field
¶
How the distractor is chosen: 'max_dissimilar', 'unexplored_theme', or 'random'.
distractor_probability
pydantic-field
¶
Chance of injecting the distractor on a given request (1.0 = always, 0.35 = occasional).
distractor_slots
pydantic-field
¶
Candidate 1-based positions where the distractor may land; one is picked at random.
cold_start_min_positives
pydantic-field
¶
Positive views needed before a visitor is considered warm (the live check is simply whether they have any positives).
tag_engagement_trust_k
pydantic-field
¶
Cold-start pace for blending survey vs browsing tags: eng_trust = n_positive / (n_positive + k). Larger k means browsing takes over more slowly.
tag_match_topk
pydantic-field
¶
score_tag normalizes by the visitor's strongest this-many tag affinities, so a few strong interests aren't diluted by many weak ones.
ranking_mode
pydantic-field
¶
Ranking policy: 'static' weighted fusion, or a learned 'bandit' whose starting point is the fusion weights.
bandit_alpha
pydantic-field
¶
Bandit exploration strength (the UCB bonus). 0 means greedy / exploit only.
bandit_ridge
pydantic-field
¶
Bandit prior strength: how tightly the learned weights start pinned to the fusion weights.
bandit_explore
pydantic-field
¶
Whether to add the UCB exploration bonus when serving with the bandit.
bandit_online
pydantic-field
¶
Whether to update the bandit live as reward events arrive, instead of offline batch training.
Ports¶
ports
¶
EmbeddingModel
¶
Bases: Protocol
Text -> vector. Real impl = fastembed; test fake = deterministic.
EventSource
¶
Bases: Protocol
A source of one visitor's interaction history, as canonical events.
This is a port: anything that can return a visitor's InteractionEvents can back
it, and the recommender only depends on this interface, never on a concrete backend.
The adapter behind it does the normalization (see
ai_engine.recsys.adapters.rudderstack.normalize_events), so downstream logic never
sees source-specific payload shapes.
The online serving model. The engine serves recommendations online (documented on the
"Online Serving Model" page): app events flow
app -> RudderStack -> /api/ingest webhook, where each event is normalized and pushed
into a hot buffer (a Redis sorted set), and the user model is rebuilt from that
buffer on every event. A recommendation is then a fast read of the already-built
model, with no rebuild on the request path. In this setup EventSource is that hot
buffer, and fetch_events returns the visitor's recent events from it. (The contrast
is an offline/batch setup, where the same interface is instead backed by a data
warehouse query. PostHog is only the analytics sink; it is never read at serve time.)
fetch_events
¶
fetch_events(user_id: str) -> list[InteractionEvent]
Return one visitor's recent interaction history as canonical events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
The visitor whose events to return. |
required |
Returns:
| Type | Description |
|---|---|
list[InteractionEvent]
|
That visitor's recent |
Example
Source code in ai-engine/src/ai_engine/recsys/contracts/ports.py
DemographicsProvider
¶
Bases: Protocol
Supplies a user's survey demographics (age/gender/nationality) for the cold-start tag bridge. Source is pluggable: Postgres visitor table, survey events, or a static map. Returns {} when unknown.
UserModelStore
¶
Bases: Protocol
Materialized user model (UserSignals) for online serving.
The ingestion webhook updates this on each event so a rec request is a fast read, not a rebuild. The in-memory fake / recompute-backed impl make this a drop-in: swap to Redis without touching the recommender.
ImpressionStore
¶
Bases: Protocol
Short-lived store of the FEATURE VECTORS we served, keyed by request_id, so a later reward event (CONTENT_VIEW echoing that request_id) can be joined back to the exact context for an ONLINE bandit update. TTL'd; not durable (the Parquet log is).
ContentStore
¶
Bases: Protocol
Content structure + vectors (Qdrant). Test fake = in-memory.
Signals¶
engagement¶
engagement
¶
Pure engagement scoring. No IO. Input = plain numbers, output = float/enum.
These functions are the easiest thing to validate: feed known numbers, assert the behavior the design promises (longer dwell -> higher, abandon -> negative, ...).
estimate_reading_time
¶
estimate_reading_time(word_count: int, has_image: bool, cfg: RecConfig) -> float
Seconds a typical visitor needs to consume this content.
Source code in ai-engine/src/ai_engine/recsys/signals/engagement.py
engagement_strength
¶
engagement_strength(*, dwell_seconds: Optional[float], est_reading_time: float, end_reason: Optional[EndReason], visits: int, survey_rating: Optional[float], cfg: RecConfig) -> float
Continuous engagement in roughly [-1, 1]. Weighted blend of behavioral signals.
Source code in ai-engine/src/ai_engine/recsys/signals/engagement.py
classify_outcome
¶
classify_outcome(strength: float, cfg: RecConfig) -> Outcome
Bucket a continuous engagement strength into a discrete outcome.
strength >= positive_threshold → positive; <= negative_threshold → negative;
anything in between → neutral (ignored by the user model).
Source code in ai-engine/src/ai_engine/recsys/signals/engagement.py
signal_builder¶
signal_builder
¶
Pure construction of the USER MODEL (UserSignals) from events + content structure.
events (+ content tags/vectors) -> UserSignals. No IO: the caller fetches content
and vectors and passes them in. now is passed in too, so the function is fully
deterministic and testable.
ViewAggregate
dataclass
¶
ViewAggregate(content_id: str, dwell_seconds: Optional[float] = None, visits: int = 0, end_reason: Optional[EndReason] = None, last_ts: Optional[datetime] = None, survey_rating: Optional[float] = None)
All views of one content folded together.
aggregate_views
¶
aggregate_views(events: Sequence[InteractionEvent]) -> dict[str, ViewAggregate]
Group events by content_id and pair start/end into dwell.
Robust to the online case (start and end arrive as separate webhook events) and to sources that already carry dwell_seconds on the end event.
Source code in ai-engine/src/ai_engine/recsys/signals/signal_builder.py
build_user_signals
¶
build_user_signals(*, user_id: str, events: Sequence[InteractionEvent], contents: dict[str, Content], vectors: dict[str, Vector], now: datetime, cfg: RecConfig, demographics: Optional[dict] = None) -> UserSignals
Fold events + content structure into the user model.
Source code in ai-engine/src/ai_engine/recsys/signals/signal_builder.py
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | |
Survey¶
survey¶
survey
¶
Survey -> user-model mapping (the persona).
Survey answers become tag-affinity in the SAME taxonomy the content is tagged with, so the recommender's score_tag does persona<->content tag similarity. Supports the kwb research survey (survey:kwb:survey: q:age/q:gender/q:nationality + q:personalization_*) and the demographic onboarding quiz (age_group/gender/nationality).
Personalization questions are explicit preferences: their answer VALUE must be the canonical taxonomy label (e.g. "Forced Labor") so it matches content tags 1:1.
canon_demo_value
¶
One canonical token per answer meaning, or None for placeholder junk. Empty/junk gender collapses to 'no_answer' (a real survey outcome); junk in any other field is dropped from distributions entirely.
Source code in ai-engine/src/ai_engine/recsys/survey.py
demo_label
¶
Human label for a canonical demographic value. The server owns both the semantics (canon_demo_value) and the wording, so the dashboard renders labels verbatim instead of re-deriving them (docs/debt-payload-scatter.md D3).
Source code in ai-engine/src/ai_engine/recsys/survey.py
extract_demographics
¶
Pull the demographic fields out of raw survey answers into a flat dict.
Reads the five demographic fields (age, gender, nationality, province,
personal_connection) from a survey or identify payload and returns them as plain
{field: value}. Each field can arrive under several question ids or trait keys (for
example age as q:age, age_group, or age); the first id that is present wins, and
for a multi-select answer the first value is taken.
The values are kept raw (for example "55_64", not the taxonomy label
"age 55-64"). This function is only for storing and inspecting who the visitor is
(it populates UserSignals.demographics). Turning demographics into weighted taxonomy
tags for matching is a separate step, survey_affinity / _demographic_affinity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
answers
|
dict
|
|
required |
Returns:
| Type | Description |
|---|---|
dict
|
|
dict
|
(empty dict if none are present). |
Example
Source code in ai-engine/src/ai_engine/recsys/survey.py
split_survey_answers
¶
Group raw survey answers for the holistic visitor profile.
The app sends every question TWICE: a plain key with the human display text ("knowledge_level": "Oneens") and a q:-prefixed key with the canonical code ("q:knowledge_level": "2"). Both used to render as separate duplicate rows; now each base question appears once, preferring the human display value. Email answers are dropped entirely (PII — the email_shared flag covers them), and zero-width characters are stripped from values.
Source code in ai-engine/src/ai_engine/recsys/survey.py
survey_affinity
¶
Turn raw survey answers into weighted taxonomy tags (the visitor's persona).
Emits {"facet:label": weight} keyed in the SAME taxonomy the content is tagged
with, so score_tag can match persona against content directly. Two kinds of answer
contribute, at deliberately different weights:
- Demographics (age, gender, nationality, NL province) map to
person_who.*facets at modest weights: age 0.5, gender 0.3, nationality 0.4, province 0.5. A "core country" is an origin the collection tags content for specifically (CORE_COUNTRIES= Netherlands, Germany, Poland); a nationality outside that set has no country tag of its own, so it also emits theInternationalrollup (0.3) and matches content tagged for that non-core complement. - Personalization preferences (theme / interest / area) are what the visitor
explicitly picked, so they get the strongest weight (1.0). The answer value IS the
taxonomy label, run through
_canonical_labelso spelling/separator variants (e.g. "forced labour") still line up with the content label ("Forced Labor").
Multi-select answers emit one key per selected value. These land in the survey side
of tag_affinity; build_user_signals blends them with engagement so they dominate
on cold start (see ai_engine.recsys.signals.signal_builder.build_user_signals).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
answers
|
dict
|
|
required |
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
|
Example
survey_affinity({
"q:age": "55_64",
"q:gender": "female",
"q:nationality": "france", # not a core country
"q:personalization_theme": "forced labour",
})
# {
# 'person_who.age_group:age 55-64': 0.5,
# 'person_who.gender_and_age:female': 0.3,
# 'person_who.city_village_country:From: France': 0.4,
# 'person_who.city_village_country:International': 0.3,
# 'theme_what:Forced Labor': 1.0,
# }
Source code in ai-engine/src/ai_engine/recsys/survey.py
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 | |
Ranking¶
scorers¶
scorers
¶
Pure scorers. CONTRACT: every scorer returns a value in [0, 1].
That contract is what makes the weighted sum in fusion valid without rescaling.
cosine
¶
Cosine similarity in [-1, 1]; 0 if either side is missing/zero.
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
score_semantic
¶
score_semantic(signals: UserSignals, candidate_vector: Optional[Vector]) -> float
How close the candidate is to the user's taste vector. -> [0, 1].
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
haversine_m
¶
Great-circle distance between two lat/lon points, in metres.
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
score_geo
¶
score_geo(content: Optional[Content], ref: Optional[tuple], scale_m: float) -> float
Proximity of the candidate to a reference point (the user's CURRENT location, a per-request signal, NOT part of the stored user model). exp(-distance/scale). -> [0,1]; 0 if either side lacks coordinates. Independent of the tag system.
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
score_recency
¶
score_recency(signals: UserSignals, candidate_vector: Optional[Vector]) -> float
Sequence awareness: closeness to the user's MOST-RECENT view (vs the whole-history taste vector). Boosts 'more like what you just read'. -> [0, 1].
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
score_tag
¶
score_tag(signals: UserSignals, content: Optional[Content], topk: int = 6) -> float
How well a candidate item matches the visitor's tag interests, in [0, 1].
The visitor's persona is a set of weighted tags (signals.tag_affinity, each tag to a
weight in [0, 1]); the content carries its own weighted tags. For every tag the visitor
has that the content also has, this adds visitor_weight * content_weight. That sum is
then divided by the total weight of the visitor's topk strongest interests, giving a
score where roughly 1.0 means the content covers the visitor's top interests and 0.0
means it shares none of their tags.
score = sum over shared tags of (visitor_weight * content_weight)
/ (sum of the visitor's topk largest weights)
Dividing by only the strongest topk interests (not every tag the visitor has) stops a
long tail of faint browsing tags from shrinking the score: a visitor with one dominant
survey interest plus many weak ones still scores well on content that hits the dominant
one, instead of having it averaged away.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
signals
|
UserSignals
|
The visitor model; only |
required |
content
|
Optional[Content]
|
The candidate item (its |
required |
topk
|
int
|
How many of the visitor's strongest interests to normalize by. |
6
|
Returns:
| Type | Description |
|---|---|
float
|
A match score in [0, 1]. |
Example
# Visitor persona (content tag weights are all 1.0 here):
# theme_what:forced labor 1.0
# theme_how.type_of_stores:personal stories 0.7
# person_who.age_group:age 55-64 0.5
# topk = 6, so the denominator is 1.0 + 0.7 + 0.5 = 2.2
# Story A, tagged {forced labor, personal stories}:
# matched = 1.0*1.0 + 0.7*1.0 = 1.7 -> score = 1.7 / 2.2 = 0.77
# Story C, tagged {daily life} (none of the visitor's interests):
# matched = 0 -> score = 0.0
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
score_aversion
¶
score_aversion(signals: UserSignals, content: Optional[Content]) -> float
Overlap between the candidate's tags and themes the user DISLIKED. -> [0, 1]. Mirrors score_tag over tag_aversion; fused with a NEGATIVE weight so a candidate sharing themes with abandoned content is pushed down.
Source code in ai-engine/src/ai_engine/recsys/ranking/scorers.py
fusion¶
fusion
¶
Pure fusion + diversity. No IO.
weighted_fuse: combine per-scorer [0,1] scores into one fused score + keep the breakdown (explainability). mmr_rerank: greedy Maximal Marginal Relevance to avoid returning 10 near-identical stories.
weighted_fuse
¶
weighted_fuse(per_scorer: dict[str, float], weights: FusionWeights) -> tuple[float, dict[str, float]]
Return (fused_score, breakdown). breakdown[s] = weight[s] * score[s].
Source code in ai-engine/src/ai_engine/recsys/ranking/fusion.py
mmr_rerank
¶
mmr_rerank(candidates: list[ScoredCandidate], vectors: dict[str, Optional[Vector]], *, lambda_: float, limit: int) -> list[ScoredCandidate]
Greedy MMR. Relevance = candidate.final_score; diversity = cosine between candidate vectors. lambda_=1 pure relevance, lambda_=0 pure diversity.
Returns up to limit items. Stable: the first pick is always the top-relevance
candidate (no selected set to penalize against yet).
Source code in ai-engine/src/ai_engine/recsys/ranking/fusion.py
bandit¶
bandit
¶
Contextual bandit ranking policy (linear, LinUCB-style). PURE: no IO, no numpy.
Replaces the STATIC weighted fusion with a LEARNED linear reward model over the SAME per-scorer features. The hand-set FusionWeights become the bandit's PRIOR (θ0 = weights via a ridge prior), so enabling the bandit starts at EXACTLY the current behavior and learns away from it as (features -> reward) data arrives.
context x = [semantic, tag, recency, aversion, geo] (per candidate)
action = recommend a candidate
reward r = realized engagement strength of the view it produced
model E[r | x] = θ·x with UCB exploration bonus α·√(xᵀ A⁻¹ x)
Online update (LinUCB): A += x xᵀ ; b += r x ; θ = A⁻¹ b. The reward is delayed (a view ends after we serve), so updates are applied by the OFFLINE trainer that joins the served-log (features) to the event-log (reward). d is tiny (~6), so a hand-rolled matrix inverse keeps this dependency-free.
LinearBandit
¶
LinearBandit(A: list[list[float]], b: list[float], *, alpha: float = 0.3, feature_order: Sequence[str] = FEATURE_ORDER, ridge: float = 1.0, n_updates: int = 0)
Source code in ai-engine/src/ai_engine/recsys/ranking/bandit.py
with_prior
classmethod
¶
with_prior(weights: dict, *, ridge: float = 1.0, alpha: float = 0.3, feature_order: Sequence[str] = FEATURE_ORDER) -> 'LinearBandit'
Ridge prior centred on the static fusion weights: A0 = ridgeI, b0 = ridgew => theta0 = A0^-1 b0 = w. Starts identical to weighted fusion, then learns.
Source code in ai-engine/src/ai_engine/recsys/ranking/bandit.py
update
¶
Online LinUCB update: A += wx xT ; b += wreward*x. weight < 1 down-weights
a sample (e.g. bootstrap data from another study, so live data dominates later).
Source code in ai-engine/src/ai_engine/recsys/ranking/bandit.py
health
¶
Training diagnostics: how much data each weight has seen and how confident it is. std[i] = posterior std of theta_i (sqrt of A^-1 diagonal) -> shrinks with data. data[i] = A_ii - ridge = total x_i^2 mass observed -> 0 means that feature never fired.
Source code in ai-engine/src/ai_engine/recsys/ranking/bandit.py
rank_scores
¶
Score each candidate by θ·x (+ UCB bonus). Inverts A once for the batch.
Source code in ai-engine/src/ai_engine/recsys/ranking/bandit.py
feature_vector
¶
Ordered context vector from a per-scorer dict (missing scorer -> 0.0).
Adapters¶
rudderstack¶
rudderstack
¶
Pure RudderStack -> InteractionEvent normalizer.
No IO, no infra: takes raw RudderStack track payloads (the same shape RudderStack
delivers to a webhook or writes to its warehouse) and maps them to canonical events.
This is the single place source-specific shape is handled, and it is fully testable
with plain dicts. A PostHog normalizer would live beside this and emit the same type.
normalize_content_id
¶
'content_1234' -> '1234'; '841' -> '841'; None -> None.
Bridges the event schema's string ids to the Qdrant integer point ids.
Source code in ai-engine/src/ai_engine/recsys/adapters/rudderstack.py
normalize_event
¶
normalize_event(raw: dict) -> Optional[InteractionEvent]
Map one RudderStack track/identify payload to an InteractionEvent (or None).
Source code in ai-engine/src/ai_engine/recsys/adapters/rudderstack.py
normalize_events
¶
normalize_events(raws: Iterable[dict]) -> list[InteractionEvent]
Normalize a batch of raw RudderStack payloads into InteractionEvents.
Applies normalize_event to each, drops any that don't map to a known event
(returns None), and returns the survivors sorted by timestamp (oldest first).
This is the single entry the POST /api/ingest webhook calls on every request.
Note on dwell time: a CONTENT_VIEW_ENDED does NOT carry the dwell duration. The
event catalog's ended-event only sends the end reason; dwell is computed later, a
posteriori, by pairing the CONTENT_VIEW_STARTED and CONTENT_VIEW_ENDED timestamps
(ai_engine.recsys.signals.signal_builder.aggregate_views, span = end_ts - start_ts).
An explicit details.dwell_seconds is read only as a fallback if some source happens
to provide it. So dwell_seconds below is None on the ended event itself.
Examples:
A track view event. Note content_5567 is stripped to 5567, the
candidates become impressions (soft negatives), and request_id is lifted
out so a later reward can be joined to the impression that produced it:
raw = {
"type": "track",
"event": "CONTENT_VIEW_ENDED",
"userId": "u-42",
"timestamp": "2026-06-29T12:00:00Z",
"properties": {
"content": {"content_id": "content_5567"},
"details": {"reason": "next_button", "request_id": "req-abc"},
"context": {"session_id": "s-9",
"candidates": ["content_5567", "content_5568"]},
},
}
normalize_events([raw])
# [InteractionEvent(user_id="u-42", event="CONTENT_VIEW_ENDED",
# content_id="5567", dwell_seconds=None, # derived later from the START/END pair
# end_reason=EndReason.next_button, request_id="req-abc", session_id="s-9",
# impressions=["5567", "5568"], survey_answers={}, ts=datetime(...))]
An identify call carries survey/demographic traits instead of a view (no
event field → routed to IDENTIFY, traits land in survey_answers):
raw = {"type": "identify", "userId": "u-42",
"traits": {"q:age": "55_64", "q:personalization_theme": "forced labour"}}
normalize_events([raw])
# [InteractionEvent(user_id="u-42", event="IDENTIFY",
# survey_answers={"q:age": "55_64",
# "q:personalization_theme": "forced labour"}, ...)]
Source code in ai-engine/src/ai_engine/recsys/adapters/rudderstack.py
qdrant_store¶
qdrant_store
¶
Qdrant-backed ContentStore. Requires qdrant-client (not needed for tests).
Tags live in the point payload (decided design): a tags list of {facet,label,weight}
plus a flat tag_labels ("facet:label") KEYWORD-indexed field used for tag recall.
QdrantContentStore
¶
Source code in ai-engine/src/ai_engine/recsys/adapters/qdrant_store.py
raw_payloads
¶
Raw Qdrant payloads keyed by id (image_url / public_url / creator / time_metadata …)
, the full content the recsys Content model drops. Used to open item detail cards.
Source code in ai-engine/src/ai_engine/recsys/adapters/qdrant_store.py
vocab
¶
Distinct tag vocabulary (facet -> labels, "facet:label" -> count) for the collection. Scrolls payloads once; used by the evaluation tool to build / validate synthetic personas.
Source code in ai-engine/src/ai_engine/recsys/adapters/qdrant_store.py
redis_store¶
redis_store
¶
Redis-backed online stores. Requires redis (not needed for tests).
- RedisEventBuffer: hot per-user event buffer (sorted set by ts, time-windowed).
The ingestion webhook calls
append; the updater reads viafetch_events. - RedisUserModelStore: materialized UserSignals cache (one JSON value per user).
RedisImpressionStore
¶
RedisImpressionStore(client: 'redis.Redis', *, ttl_seconds: int = 24 * 3600, key_prefix: str = 'imp')
request_id -> {content_id: feature_vector} (JSON), TTL'd. Feeds online bandit updates.
Source code in ai-engine/src/ai_engine/recsys/adapters/redis_store.py
consume
¶
Drop a served item's context after it has produced one reward, so a redelivered reward (retry / at-least-once webhook) can't double-count the update.
Source code in ai-engine/src/ai_engine/recsys/adapters/redis_store.py
RedisUserModelStore
¶
RedisUserModelStore(client: 'redis.Redis', *, ttl_seconds: int = 7 * 24 * 3600, key_prefix: str = 'umodel')
Source code in ai-engine/src/ai_engine/recsys/adapters/redis_store.py
iter_signals
¶
iter_signals() -> list[UserSignals]
All materialized user models (for cohort-wide content statistics).
Source code in ai-engine/src/ai_engine/recsys/adapters/redis_store.py
fastembed_model¶
fastembed_model
¶
fastembed-backed EmbeddingModel. Requires fastembed (not needed for tests).
Orchestration¶
recommender¶
recommender
¶
Serving side: read the user model, match it against content structure, rank.
Reads the materialized UserSignals from the UserModelStore, so a request is a fast read + candidate scoring, not a rebuild.
Recommender
¶
Recommender(content_store: ContentStore, model_store: UserModelStore, cfg: RecConfig, policy: Optional[LinearBandit] = None)
Read-time orchestrator: user model + content stores → a ranked Recommendation.
Generates candidates (semantic / tag / filter / geo), scores each with the pure
scorers, fuses them (or overrides with the learned bandit when
cfg.ranking_mode == "bandit"), diversifies with MMR, and injects a distractor.
Constructor-injected stores and config keep the core testable without IO.
Source code in ai-engine/src/ai_engine/recsys/recommender.py
recommend
¶
recommend(user_id: str, *, filter: Optional[str] = None, near: Optional[tuple] = None, geo_radius_m: Optional[float] = None) -> Recommendation
Recommend for a stored user. Loads their UserSignals (empty persona if none
yet: cold-start diverse fallback, never empty-handed), then delegates to
recommend_for_signals. filter restricts to a tag (e.g. an AR location);
near/geo_radius_m add an independent geo restriction.
Source code in ai-engine/src/ai_engine/recsys/recommender.py
score_features
¶
score_features(signals: UserSignals, content, vec, cfg: RecConfig, near: Optional[tuple] = None) -> dict
The per-scorer feature dict for one candidate: the SINGLE source of truth for the context vector, shared by serving (Recommender) and offline replay/bootstrap.
Source code in ai-engine/src/ai_engine/recsys/recommender.py
updater¶
updater
¶
Ingestion side: events -> user model -> store.
The webhook appends each RudderStack event to the EventSource buffer, then calls
refresh to rebuild the materialized UserSignals and save it. Rebuild-from-buffer
(rather than fragile true-incremental decay math) keeps build_user_signals as the
single source of truth, while staying fast (hot buffer read + pure fold).
UserModelUpdater
¶
UserModelUpdater(content_store: ContentStore, model_store: UserModelStore, cfg: RecConfig)
Source code in ai-engine/src/ai_engine/recsys/updater.py
build
¶
build(user_id: str, events: Sequence[InteractionEvent], *, now: datetime, demographics: Optional[dict] = None) -> UserSignals
Fold events into the user model (fetching only the content they touched).
Source code in ai-engine/src/ai_engine/recsys/updater.py
refresh
¶
refresh(user_id: str, source: EventSource, *, now: datetime, demographics: Optional[dict] = None) -> UserSignals
Rebuild one visitor's user model from their buffered events and persist it.
Called once per visitor touched by an ingest batch (see
ai_engine.recsys.api.ingest), right after the new events are appended to the
buffer. It pulls that visitor's recent events back out of the hot buffer,
rebuilds the whole UserSignals from scratch via build (rebuild-from-buffer,
not incremental, so build_user_signals stays the single source of truth), saves
it to the UserModelStore, and returns it. The next /api/recommend reads this
freshly saved model, so recommendations reflect the just-ingested events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
The visitor whose model to rebuild. |
required |
source
|
EventSource
|
The event buffer to read this visitor's recent events from. |
required |
now
|
datetime
|
Current time, passed through for recency decay (kept as an argument so the rebuild is deterministic and testable). |
required |
demographics
|
Optional[dict]
|
Optional cold-start demographics (age/gender/nationality/ province) merged into the survey side of the tag affinity. |
None
|
Returns:
| Type | Description |
|---|---|
UserSignals
|
The rebuilt, already-persisted |
Example
updater = UserModelUpdater(content_store, model_store, cfg)
buffer.append(view_event) # a fresh CONTENT_VIEW_ENDED for u-42
signals = updater.refresh(
"u-42", buffer, now=datetime.now(timezone.utc),
demographics={"age": 60, "province": "Drenthe"},
)
signals.is_cold # False once the visitor has a positive view
# False
signals.tag_affinity # rebuilt from ALL of u-42's buffered events
# {'theme_what:forced labor': 1.0, 'person_who.province_netherlands:drenthe': 0.7}
Source code in ai-engine/src/ai_engine/recsys/updater.py
composition¶
composition
¶
Composition root: assemble the recsys components from environment.
If REDIS_URL / QDRANT_API_URL are set, use the real adapters; otherwise fall back to in-memory fakes (with dev fixtures) so the service runs locally with no infra. This is the ONE place IO backends are chosen, everything else takes ports.
ComponentManager
¶
Builds + caches one Components per tenant, over shared Redis & Qdrant clients.
Source code in ai-engine/src/ai_engine/recsys/composition.py
tenant_store
property
¶
Runtime tenant registry, merged over the TENANTS_PATH baseline. Backing preference: durable file on the PVC (TENANT_STORE_PATH) > Redis > in-memory. File store keeps /admin-added tenants permanent (no redeploy, survives a Redis wipe).
list_tenants
¶
All tenants: config baseline + runtime-created, runtime wins. For the admin UI. API keys are NEVER returned raw, only a count, so the admin view can't leak secrets.
Source code in ai-engine/src/ai_engine/recsys/composition.py
tenant_for_key
¶
Resolve a per-tenant API key to its tenant_id (runtime store first, then baseline). Matches the key's sha256 against stored hashes (constant time); also tolerates legacy plaintext keys. Returns None for the global key / unknown keys.
Source code in ai-engine/src/ai_engine/recsys/composition.py
build_components_for
¶
Build a tenant-scoped Components: its own Qdrant collection + Redis key-prefix + config / bandit / cluster / event-log, sharing the manager's Redis & Qdrant clients.
Source code in ai-engine/src/ai_engine/recsys/composition.py
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
api¶
api
¶
FastAPI surface for the recommendation engine.
- POST /api/ingest : the ingest WEBHOOK. RudderStack POSTs user events here (single object or list). Normalize -> buffer -> rebuild the user model.
- GET /api/recommend: serve recommendations for a user (reads the user model).
- GET /api/usermodel: debug, inspect the current user model.
Mount router into the main service, or run app standalone. With no REDIS_URL /
QDRANT_API_URL set it runs fully in-memory on dev fixtures.
PreviewSpec
pydantic-model
¶
Bases: BaseModel
A hand-authored user model for testing recs without going through events.
Fields:
-
tag_affinity(dict[str, float]) -
like_items(list[str]) -
demographics(dict) -
limit(Optional[int])
EvalRun
pydantic-model
¶
Bases: BaseModel
Run a synthetic persona across scenarios (module-level so FastAPI reads it as a body).
Fields:
-
spec(PreviewSpec) -
user_id(Optional[str]) -
scenarios(Optional[list[dict]]) -
cold(bool)
make_tenant_admin_router
¶
Control-plane: runtime tenant management (list / create / delete) WITHOUT a redeploy. A separate router so it stays out of the app-facing serving surface. INGEST_API_KEY-guarded. Note: this registers the tenant SLICE; its content must still be ingested into the tenant's Qdrant collection (content-engine) separately.
Source code in ai-engine/src/ai_engine/recsys/api.py
create_app
¶
Build the FastAPI app. Pass a fixed Components for single-tenant/tests;
omit it for the multi-tenant server (a ComponentManager + tenant middleware
resolve a per-tenant Components from the X-Tenant-Id header). Mounts the
/api router, the search router, health, and the inspector dashboard.
Source code in ai-engine/src/ai_engine/recsys/api.py
1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 | |
Testing helpers¶
fakes¶
fakes
¶
In-memory implementations of the three ports + the user-model store.
These let the whole pipeline run offline, deterministic, no Qdrant / Redis / network. Each satisfies the corresponding Protocol in contracts.ports.
FakeContentStore
¶
FakeContentStore(contents: dict[str, Content], vectors: dict[str, Vector], payloads: Optional[dict[str, dict]] = None)
ContentStore backed by dicts. search_vector = brute-force cosine; search_tags = tag-key overlap.
Source code in ai-engine/src/ai_engine/recsys/testing/fakes.py
FakeEventSource
¶
FakeEventSource(events_by_user: Optional[dict[str, list[InteractionEvent]]] = None)
EventSource backed by an in-memory per-user buffer (mimics the Redis hot buffer fed by the ingestion webhook).
Source code in ai-engine/src/ai_engine/recsys/testing/fakes.py
InMemoryImpressionStore
¶
InMemoryUserModelStore
¶
fixtures¶
fixtures
¶
A tiny, hand-built Bergen-Belsen content world with KNOWN structure, so synthetic scenarios have a predictable right answer.
Three semantic clusters on orthogonal axes (6-dim vectors), each with matching expert tags from the real taxonomy (theme_what facet):
A = Forced Labor axis 0
B = Family / Children axis 1
C = Liberation axis 2
make_payloads
¶
Raw payload dicts (location, time_metadata, …) matching the fixture world.
view_events
¶
view_events(user_id: str, content_id: str, *, dwell: float, reason: str, base_ts: datetime, visits: int = 1) -> list[InteractionEvent]
Emit a START + END pair (separate events) for one content.