# Spec: Speaker Provenance no MemoryFact (Part 1/2)

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

## Objective

Persistir quem falou (`speaker`) em cada fato extraído, para que consumidores do SDK saibam a provenance de cada memória.

## Context

O `speaker_name` é passado no `write()`, usado nos prompts de extração pra resolver pronomes ("eu" → "Pedro"), e depois descartado. O fato resultante sabe **sobre quem é** (via `entity_key`) mas não sabe **de quem veio**. Nenhum concorrente persiste essa informação nativamente — é um diferencial do Arandu.

O campo `speaker` é pré-requisito conceitual pro Part 2 (entity filter no retrieve), mas é independente na implementação — pode ser shippado e usado sozinho.

## Definition of Done

1. `MemoryFact` model tem campo `speaker` (String, nullable).
2. `_add_fact()` e `_update_fact()` em `upsert.py` gravam o `speaker_name` no campo `speaker` do fato.
3. `FactDetail` dataclass inclui campo `speaker: str | None` e `_fact_to_detail()` o mapeia.
4. `ScoredFact` (retornado pelo `retrieve()`) inclui campo `speaker: str | None`.
5. Fatos antigos (sem speaker) retornam `speaker=None` — sem quebrar nada.
6. Testes cobrem: fato criado com speaker preenchido, fato sem speaker (backward compat), speaker no FactDetail e ScoredFact.
7. Docs atualizados em `docs/en/` e `docs/pt-BR/` — tabelas de FactDetail e ScoredFact com campo speaker.

## Scope

### Código

- **`src/arandu/models.py`**: campo `speaker: Mapped[str | None] = mapped_column(String, nullable=True)` no `MemoryFact`, após `source_context`.

- **`src/arandu/write/upsert.py`**:
  - `_add_fact()` recebe parâmetro `speaker: str | None` e grava no `MemoryFact(speaker=speaker)`.
  - `_update_fact()` recebe parâmetro `speaker: str | None` e grava no novo fato.
  - `execute_upsert()` recebe parâmetro `speaker: str | None` e propaga pras funções `_add_fact()` e `_update_fact()`.

- **`src/arandu/write/pipeline.py`**: passa `speaker_name` pro `execute_upsert(speaker=speaker_name)`.

- **`src/arandu/client.py`**:
  - `FactDetail`: campo `speaker: str | None = None`.
  - `_fact_to_detail()`: mapeia `fact.speaker`.
  - `ScoredFact`: campo `speaker: str | None = None` (no client.py).

- **`src/arandu/read/pipeline.py`**: `ScoredFact` (interno) recebe campo `speaker` e é mapeado do `fact.speaker`.

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

### Testes

- **`tests/test_client_crud.py`**: verificar que `FactDetail` inclui `speaker`.
- **`tests/test_upsert_speaker.py`** (novo): verificar que `_add_fact()` e `_update_fact()` gravam speaker, e que `execute_upsert()` propaga.

### Docs

- **`docs/en/getting-started.md`**: tabelas de FactDetail e ScoredFact com campo speaker.
- **`docs/pt-BR/getting-started.md`**: idem.

## Anti-scope

- **Filtro por `speaker` no `retrieve()` ou `get_all()`** — campo é persistido e exposto, mas não filtrável no v0.
- **Filtro por `entity_keys`** — é Part 2.
- **Alembic migration** — `init_db()` com `create_all()` cuida de novos deploys. Bancos existentes: `ALTER TABLE memory_facts ADD COLUMN speaker VARCHAR` manual.
- **Index no campo `speaker`** — sem index, avaliar depois.

## Technical Decisions

### 1. Campo nullable (não NOT NULL)

**Decisão:** `speaker` é nullable.

**Justificativa:** Fatos antigos não têm speaker. Forçar NOT NULL quebraria backward compat. `None` significa "speaker desconhecido" (fato pré-feature).

### 2. Speaker no ScoredFact (não só no FactDetail)

**Decisão:** Adicionar `speaker` também no `ScoredFact` retornado pelo `retrieve()`.

**Justificativa:** O consumidor precisa da provenance tanto quando lê via CRUD (`get/get_all` → `FactDetail`) quanto quando busca via `retrieve()` → `ScoredFact`. Sem isso, a informação estaria disponível no CRUD mas não no retrieve, que é o fluxo principal.

### 3. Speaker é string livre (não FK)

**Decisão:** `speaker` é `String` simples, não FK pra MemoryEntity.

**Justificativa:** O speaker pode ser qualquer nome — "Pedro", "Assistente", "Sistema". Não precisa existir como entidade no grafo. FK adicionaria constraint desnecessário.

## Applicable Patterns

- **Data Access** (`.vibeflow/patterns/data-access.md`): `mapped_column()` para novo campo, propagação de parâmetro pelo pipeline.
- **Testing** (`.vibeflow/patterns/testing.md`): mock-based, `make_fact()` com override de `speaker`.
- **Conventions** (`.vibeflow/conventions.md`): Google docstrings, type hints strict, `snake_case`.

## Risks

1. **`create_all()` não adiciona coluna em tabela existente** — SQLAlchemy `create_all()` cria tabelas novas mas não faz ALTER em existentes. Para bancos já rodando, o dev precisa rodar `ALTER TABLE` manual. Mitigação: documentar no changelog.

2. **Testes existentes que mocam `MemoryFact`** — `make_fact()` em `conftest.py` não inclui `speaker`. Testes existentes continuam passando (mock retorna None pra atributos não setados), mas é bom adicionar `speaker` aos defaults do `make_fact()`.
