# PRD: Speaker Provenance + Entity Filter no Retrieve

> Generated via /vibeflow:discover on 2026-04-08

## Problem

O `retrieve()` do Arandu retorna todos os fatos de um `agent_id` sem possibilidade de filtrar por entidade. Se o agente quer "o que eu sei sobre a Ana?", ele depende 100% da busca semântica acertar — não tem como restringir por entity_key. Todos os concorrentes têm algum filtro na leitura (Mem0: `user_id`/`agent_id`, Zep: `group_ids`, Supermemory: `containerTag`).

Além disso, o `speaker_name` — que identifica quem falou a mensagem — é usado na extração para resolver pronomes mas **não é persistido no fato**. O fato sabe sobre quem é (via `entity_key`), mas não sabe de quem veio. Nenhum concorrente faz isso nativamente, mas é uma informação valiosa que o Arandu já tem no momento do write e simplesmente descarta.

Cenário: Pedro diz "Ana é designer" e depois o assistente descobre "Ana mora em Berlim". Ambos viram fatos sobre `person:ana`. Mas quando o agente recupera esses fatos, não sabe qual veio do Pedro e qual ele mesmo descobriu. A informação de provenance se perdeu.

## Target Audience

Devs que usam o Arandu SDK para assistentes com memória — especialmente assistentes pessoais que conversam com um usuário e também agem por conta própria (pesquisas, criação de documentos, etc.).

## Proposed Solution

Duas mudanças independentes:

1. **Persistir `speaker` no `MemoryFact`** — campo nullable que grava quem falou a mensagem de onde o fato foi extraído. Metadata de provenance, sem impacto em extração ou retrieval. Exposto no `FactDetail`.

2. **Filtro `entity_keys` no `retrieve()` e `get_all()`** — parâmetro opcional. Quando presente, restringe os resultados a fatos linkados às entidades especificadas (via `MemoryFactEntityLink`). Sem filtro, comportamento idêntico ao atual.

## Success Criteria

1. Novos fatos gravados via `write()` têm o campo `speaker` preenchido com o `speaker_name`.
2. `FactDetail` retornado por `get()` e `get_all()` inclui o campo `speaker`.
3. `retrieve(agent_id, query, entity_keys=["person:ana"])` retorna só fatos linkados à entidade Ana.
4. `get_all(agent_id, entity_keys=["person:ana"])` retorna só fatos linkados à entidade Ana.
5. Sem `entity_keys`, comportamento é idêntico ao atual (backward compatible).
6. Fatos antigos (sem speaker) continuam funcionando — campo é nullable.

## Scope v0

- Campo `speaker` no modelo `MemoryFact` (String, nullable)
- Pipeline de write grava o `speaker_name` no campo `speaker` do fato criado
- Campo `speaker` adicionado ao `FactDetail`
- Parâmetro `entity_keys: list[str] | None` no `retrieve()` — propagado até `semantic_search()` e `keyword_search()` via join com `MemoryFactEntityLink`
- Parâmetro `entity_keys: list[str] | None` no `get_all()` — mesmo filtro via join
- Testes
- Docs EN + PT-BR

## Anti-scope

- **Filtro por `speaker` no `retrieve()`** — o campo é persistido mas não é filtrável no v0. Nenhum concorrente faz filtro por speaker. Se virar dor real, adiciona depois.
- **Filtros complexos (AND/OR/NOT)** — v0 é só `entity_keys`, lista simples.
- **Filtro por `category`, data, confidence** — pode vir depois.
- **Alembic migration** — o SDK usa `init_db()` com `create_all()`. Nova coluna nullable é adicionada automaticamente em novos deploys. Para bancos existentes, o dev roda um `ALTER TABLE` manual ou a migration Alembic que já existe no projeto.
- **Index no campo `speaker`** — avaliar depois se performance exigir.
- **Filtro no `entities()`** — já retorna só entidades do agent_id.

## Technical Context

### O que já existe

- **`MemoryFact.entity_key`** — todo fato tem entity_key. Fatos também são linkados a entidades via `MemoryFactEntityLink` (N:N — um fato pode linkar a múltiplas entidades).
- **`speaker_name`** no write pipeline — passa por `run_write_pipeline()` → `extract_from_message()`. Usado nos prompts pra resolver pronomes. Chega até o `execute_upsert()` mas não é gravado no `MemoryFact`.
- **`semantic_search()` e `keyword_search()`** em `src/arandu/read/retrieval.py` — queries com `select(MemoryFact).where(...)`. Adicionar `.join(MemoryFactEntityLink).where(entity_key.in_(...))` é cirúrgico.
- **`MemoryFactEntityLink`** — tabela de ligação com `fact_id`, `entity_key`, `agent_id`, `is_primary`. Já tem index em `(agent_id, entity_key)`.

### Patterns a seguir

- **Data Access** — `.where()` condicional, join via relationship ou explicit join.
- **Conventions** — Google docstrings, type hints strict, backward compat via parâmetros opcionais com default `None`.

## Open Questions

None.
