# recommender The core of oO. Takes a user + context, returns **one** tip. ## Contract ``` POST /api/recommend { } (user inferred from session) → { tip: { id, content, source, kind, sourceId?, rationale?, createdAt } } POST /api/tip/:id/feedback { action: "done"|"dismiss"|"snooze"|"helpful"|"not_helpful", dwellMs? } → { ok: true } ``` ## Pipeline 1. **Signals** — `SignalAggregator.fetchAll(userId)` fans out to all registered `SignalSource` implementations in parallel. Currently: `TodoistSignalSource`. Add a source via `aggregator.register(new MySource())`. 2. **LLM candidates** — `POST /generate` on `ml/serving` returns `TipCandidate[]` from the `tip-generator` LiteLLM alias. 3. **Scoring** — all candidates sent to `ml/serving` active policy (`POST /score/egreedy`). Falls back to random if `ml/serving` is unreachable. 4. **Shadow policies** — active policy runs shadow policies in the same request for offline comparison (ADR-0002). Currently: `egreedy-v2` shadows `egreedy-v1`. 5. **Persistence** — `tipViews` + `tipScores` rows written on every serve; `tipFeedback` row on reaction. 6. **Reward delivery** — reaction triggers `POST /reward/egreedy` on `ml/serving` with inferred reward value. ## Signal normalization Signals carry `features: Record` (bandit-ready) and `metadata: Record` (source-specific raw fields). The bandit treats features as an opaque dict — sources own their feature names. See ADR-0009. ## Policy registry | Policy | Status | Notes | |--------|--------|-------| | `random` | Fallback | Used when ml/serving is unreachable | | `egreedy-v1` | Shadow | d=7, ADR-0007 | | `egreedy-v2` | **Active** | d=12 + profile features, ADR-0012 | Shadow → active promotion requires offline sim + online agreement (ADR-0002). ## Extraction criteria Extract to its own process at scaling hotspot: when `POST /recommend` p99 latency exceeds SLA or when recommendation CPU displaces API serving on shared host.