# Spec: Entity Filter no Retrieve e Get All (Part 2/2)

> Source PRD: `.vibeflow/prds/retrieve-filters.md`
> Generated: 2026-04-08

## Objective

Permitir filtrar fatos por entidade no `retrieve()` e `get_all()`, para que o consumidor possa buscar "o que eu sei sobre a Ana?" de forma precisa.

## Context

O `retrieve()` retorna todos os fatos do `agent_id` ranqueados por relevância semântica. Não tem filtro por entidade — se o agente quer fatos sobre uma pessoa específica, depende 100% da busca semântica acertar. Todos os concorrentes têm filtro na leitura (Mem0: `user_id`/`agent_id`, Zep: `group_ids`, Supermemory: `containerTag`).

A infraestrutura pra filtrar já existe: `MemoryFactEntityLink` liga fatos a entidades (N:N) e tem index em `(agent_id, entity_key)`. Basta adicionar um JOIN condicional nas queries de retrieval.

## Definition of Done

1. `retrieve(agent_id, query, entity_keys=["person:ana"])` retorna só fatos linkados à entidade `person:ana`.
2. `retrieve()` sem `entity_keys` tem comportamento idêntico ao atual.
3. `get_all(agent_id, entity_keys=["person:ana"])` retorna só fatos linkados à entidade `person:ana`.
4. `get_all()` sem `entity_keys` tem comportamento idêntico ao atual.
5. Múltiplas `entity_keys` funcionam como OR — `entity_keys=["person:pedro", "person:ana"]` retorna fatos linkados a Pedro OU Ana.
6. Testes cobrem: filtro com uma entity, múltiplas entities, entity inexistente (retorna vazio), sem filtro (backward compat).
7. Docs atualizados em `docs/en/` e `docs/pt-BR/` — parâmetro `entity_keys` documentado no getting-started e reference.

## Scope

### Código

- **`src/arandu/client.py`**:
  - `retrieve()`: novo parâmetro `entity_keys: list[str] | None = None`, propagado pro `run_read_pipeline()`.
  - `get_all()`: novo parâmetro `entity_keys: list[str] | None = None`, aplicado via JOIN na query.

- **`src/arandu/read/pipeline.py`**: `run_read_pipeline()` recebe `entity_keys` e propaga pro `retrieve_candidates()` e `retrieve_graph_facts()`.

- **`src/arandu/read/retrieval.py`**:
  - `semantic_search()`: quando `entity_keys` presente, JOIN com `MemoryFactEntityLink` e `.where(MemoryFactEntityLink.entity_key.in_(entity_keys))`.
  - `keyword_search()`: mesmo filtro.
  - `retrieve_candidates()`: recebe e propaga `entity_keys`.

- **`pyproject.toml`** + **`src/arandu/__init__.py`**: version bump.

### Testes

- **`tests/test_client_crud.py`**: testes de `get_all()` com `entity_keys`.
- **`tests/test_retrieve_entity_filter.py`** (novo): testes do filtro no read pipeline — mock de `semantic_search` com entity_keys, verificação do JOIN.

### Docs

- **`docs/en/getting-started.md`**: exemplos de `retrieve()` e `get_all()` com `entity_keys`.
- **`docs/pt-BR/getting-started.md`**: idem.

## Anti-scope

- **Filtro por `speaker`** — campo existe (Part 1) mas não é filtrável.
- **Filtro por `category`, data, confidence** — pode vir depois.
- **Filtros compostos (AND/OR/NOT)** — `entity_keys` é lista simples (OR implícito).
- **`entity_keys` no `entities()`** — não faz sentido filtrar entidades por entidade.
- **Dedup de fatos quando múltiplas entity_keys matcham o mesmo fato** — o JOIN pode retornar o mesmo fato 2x se ele está linkado a Pedro E Ana e ambos estão no filtro. Precisa de DISTINCT. Isso está no scope.

## Technical Decisions

### 1. JOIN com MemoryFactEntityLink (não filtro por MemoryFact.entity_key)

**Decisão:** Filtrar via JOIN com `MemoryFactEntityLink`, não via `MemoryFact.entity_key` direto.

**Justificativa:** Um fato pode estar linkado a múltiplas entidades (ex: "Pedro trabalha na Stone" está linkado a `person:pedro` E `organization:stone`). O `MemoryFact.entity_key` guarda só a entidade primária. O `MemoryFactEntityLink` guarda todas. Filtrar pelo link table é mais correto.

### 2. Múltiplas entity_keys = OR (não AND)

**Decisão:** `entity_keys=["person:pedro", "person:ana"]` retorna fatos linkados a Pedro **OU** Ana.

**Justificativa:** AND seria "fatos que mencionam Pedro E Ana ao mesmo tempo" — raro e restritivo demais. OR é o padrão (Mem0 faz OR, Zep com `group_ids` faz OR). Se quiser AND, é feature futura com filtros compostos.

### 3. DISTINCT pra evitar duplicatas

**Decisão:** Adicionar `.distinct()` na query quando `entity_keys` está presente.

**Justificativa:** Um fato linkado a Pedro E Ana apareceria 2x no JOIN se ambos estão no filtro. DISTINCT resolve sem complexidade.

### 4. Filtro condicional (não muda query sem filtro)

**Decisão:** O JOIN e WHERE só são adicionados quando `entity_keys is not None`. Sem filtro, query é idêntica à atual.

**Justificativa:** Sem impacto em performance pro caso padrão. O JOIN só acontece quando o dev explicitamente pede filtro.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): `select().join().where()` condicional, `MemoryFactEntityLink` como tabela de join.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based, sem DB real.
- **Conventions** (`.vibeflow/conventions.md`): Google docstrings, type hints strict, parâmetros keyword-only com default `None`.

## Risks

1. **Performance do JOIN em queries semânticas** — `semantic_search` faz pgvector cosine distance + ORDER BY. Adicionar JOIN pode impactar performance se a tabela de links for grande. Mitigação: o JOIN é filtrado por `agent_id` + `entity_key` (index já existe: `ix_fact_entity_links_user_entity`). Em volumes normais (<100k fatos) não deve ser problema.

2. **Graph retrieval não filtrado** — `retrieve_graph_facts()` usa BFS no grafo. Adicionar `entity_keys` filter nele é mais complexo (filtrar os fatos retornados, não a travessia). Mitigação: filtrar os fatos graph após o BFS, antes do merge com semantic/keyword.

## Dependencies

- `.vibeflow/specs/retrieve-filters-part-1.md` — Part 1 (speaker provenance) deve ser implementada antes. O campo `speaker` no FactDetail/ScoredFact é adicionado lá.
