# Spec: Entity Alias Dedup

> Generated from `.vibeflow/prds/entity-alias-dedup.md` on 2026-03-19
> Budget: ≤ 6 files

## Objective

Eliminar duplicação de entities dentro de uma mesma mensagem quando o LLM reconhece que dois nomes se referem à mesma entidade (apelido/nome completo, escrita diferente, nome parcial, role/nome).

## Context

Hoje o `ExtractedEntity` só tem `name` e `type`. Quando o LLM vê "meu amigo Guili (Guilherme Maturana)", ele cria duas entities separadas porque o schema não tem onde expressar que são a mesma pessoa. O entity resolution recebe os dois nomes isolados e não consegue casá-los — "Guili" e "Guilherme Maturana" são strings completamente diferentes (fuzzy score ~0.3, abaixo do threshold mínimo de 0.5).

A extraction já entende a equivalência (produz facts de nickname e relations same_as), mas essa informação não é usada pra dedup. Resultado: ~2x de inflação em entities, facts e relations.

A solução é dar ao LLM o campo `aliases` no schema de entity, ajustar os prompts pra instruir agrupamento, e registrar os aliases no entity resolution ao criar entities novas.

## Definition of Done

1. **Schema atualizado**: `ExtractedEntity` tem campo `aliases: list[str]` com default `[]`. Parsing de extraction aceita entities com e sem aliases (backward-compatible).
2. **Prompts atualizados**: `ENTITY_SCAN_PROMPT`, `EXTRACTION_SYSTEM_PROMPT` e `REFLEXION_SYSTEM_PROMPT` instruem o LLM a criar UMA entity com aliases quando a mesma entidade aparece com múltiplos nomes. Incluem exemplos concretos.
3. **Normalização de subjects**: Após a extraction (tanto single-pass quanto multi-pass), facts e relations que referenciam um alias como subject/source/target são remapeados para o nome canônico da entity. Validado por teste.
4. **Aliases registrados no entity resolution**: Quando `_create_entity()` recebe uma `ExtractedEntity` com aliases, todos os aliases são registrados na tabela `memory_entity_aliases` via `_register_alias()`. Validado por teste.
5. **Teste end-to-end com aliases**: FakeLLMProvider retorna entities com aliases preenchidos. O pipeline completo (extraction → entity resolution) produz uma entity canônica e os aliases registrados. Nenhuma entity duplicada criada.

## Scope

### 1. `src/arandu/constants.py` — Schema change
- Adicionar `aliases: list[str] = Field(default_factory=list)` em `ExtractedEntity`

### 2. `src/arandu/write/extract.py` — Prompts + normalização
- Atualizar `ENTITY_SCAN_PROMPT`: adicionar instrução sobre aliases com exemplos (apelido, escrita diferente, nome parcial, role). Incluir `aliases` na descrição do schema JSON.
- Atualizar `EXTRACTION_SYSTEM_PROMPT` (single-pass): mesma instrução.
- Atualizar `REFLEXION_SYSTEM_PROMPT`: instruir a verificar se entities duplicadas deveriam ser agrupadas com aliases.
- Adicionar função `_normalize_extraction_subjects(output: ExtractionOutput) -> ExtractionOutput` que:
  1. Constrói um mapa `alias_name → canonical_name` a partir dos aliases de todas as entities
  2. Reescreve `fact.subject` que é um alias para o nome canônico
  3. Reescreve `relation.source` / `relation.target` que são aliases para o nome canônico
  4. Remove relations de tipo `same_as` / `also_known_as` / `nickname_of` onde source e target mapeiam pra mesma entity (já resolvidas pelo alias)
- Chamar `_normalize_extraction_subjects()` ao final de `_single_pass_extract()` e `_multi_pass_extract()`, antes do return.

### 3. `src/arandu/write/entity_resolution.py` — Registrar aliases
- Alterar `_create_entity()` pra aceitar `aliases: list[str]` (default `[]`)
- Após criar a entity com sucesso, chamar `_register_alias()` para cada alias da lista
- Alterar `resolve_entities()` pra passar `entity.aliases` ao chamar `_create_entity()` quando a entity é nova
- Também adicionar cada alias ao `alias_cache` in-memory pra que entities processadas depois no mesmo batch já encontrem via Phase 1 exact match

### 4. `tests/test_write_pipeline.py` — Testes
- `TestExtract.test_extract_parses_aliases`: Entity com aliases é parseada corretamente. `result.entities[0].aliases == ["Guili"]`.
- `TestExtract.test_extract_aliases_default_empty`: Entity sem aliases tem `aliases == []`. Backward-compatible.
- `TestExtract.test_normalize_subjects_rewrites_alias_to_canonical`: Fact com subject "Guili" é remapeado pra "Guilherme Maturana" quando entity tem `aliases: ["Guili"]`.
- `TestExtract.test_normalize_subjects_removes_same_as_self_relations`: Relation same_as entre alias e canonical é removida.
- `TestEntityResolution.test_create_entity_registers_aliases`: Mock de session verifica que `_register_alias` é chamado pra cada alias.

## Anti-scope

- **Entity resolution phases (exact/fuzzy/LLM)**: nenhuma mudança. Continuam funcionando como segunda linha de dedup cross-mensagem.
- **Dedup cross-mensagem**: se mensagem 1 cria "Guili" e mensagem 2 cria "Guilherme Maturana" sem contexto, não é resolvido aqui.
- **Merge de entities já existentes no banco**: retroactive merge é feature separada.
- **Chamada de LLM extra**: zero. Tudo dentro das chamadas já existentes.
- **Bug da tabela `memory_entities` vazia**: problema separado.
- **`facts_deleted`/`facts_unchanged` retornando `int` vs `list`**: melhoria de API separada.
- **FACT_EXTRACTION_PROMPT / RELATION_EXTRACTION_PROMPT** (multi-pass): não precisam de mudança. Eles recebem entity names já canônicos do entity scan. O LLM pode usar aliases nos facts/relations retornados, mas `_normalize_extraction_subjects()` corrige.
- **pipeline.py**: sem mudança. A normalização acontece dentro de `extract.py`, transparente pro pipeline.

## Technical Decisions

### 1. Campo `aliases` com default vazio vs schema obrigatório
**Decisão**: `aliases: list[str] = Field(default_factory=list)`.
**Justificativa**: Backward-compatible. Extraction que não retorna aliases (LLM antigo, parse parcial, fallback) funciona como hoje. Pydantic valida e preenche o default.

### 2. Normalização dentro de extract.py vs no pipeline
**Decisão**: Dentro de `extract.py`, antes do return das funções `_single_pass_extract()` e `_multi_pass_extract()`.
**Justificativa**: A extraction é responsável por entregar dados limpos. O pipeline e entity resolution não devem se preocupar com aliases nos subjects — recebem tudo já normalizado. Princípio: cada stage entrega output limpo pro próximo.

### 3. Remoção de relations same_as resolvidas vs manter
**Decisão**: Remover relations onde source e target apontam pra mesma entity canônica após normalização.
**Justificativa**: Uma relation `Guili same_as Guilherme Maturana` vira `Guilherme Maturana same_as Guilherme Maturana` após normalização — sem sentido. Manter poluiria o grafo. Relations same_as entre entities DIFERENTES (não cobertas por alias) são preservadas.

### 4. Aliases registrados dentro de `_create_entity()` vs separado
**Decisão**: Dentro de `_create_entity()`, usando o mesmo savepoint.
**Justificativa**: Se a criação da entity falha, os aliases não devem existir. Atomicidade. `_register_alias()` já usa savepoint individual e faz "first-write-wins" — safe pra chamadas duplicadas.

## Applicable Patterns

- **LLM Interaction** (`patterns/llm-interaction.md`): Prompts são module-level constants. JSON response format. Markdown fence stripping. Fail-safe on parse error.
- **Pipeline Orchestration** (`patterns/pipeline-orchestration.md`): Extraction não faz DB writes. Entity resolution faz DB writes via savepoints. Pipeline não muda.
- **Testing** (`patterns/testing.md`): FakeLLMProvider com canned JSON. `_make_extraction_response()` helper. Class-based test grouping. Sem DB.

## Risks

| Risco | Probabilidade | Mitigação |
|---|---|---|
| LLM ignora campo `aliases` e continua criando entities duplicadas | Média | O campo tem default vazio — funciona como hoje. Sem regressão. Melhora é incremental. Exemplos concretos no prompt aumentam compliance. |
| LLM preenche `aliases` errado (agrupa entities que NÃO são a mesma) | Baixa | False merge é pior que false duplicate. Mitigação: prompt enfatiza "only when the message explicitly indicates they are the same entity". Reflexion pass pode corrigir. |
| Normalização de subjects quebra facts cujo subject não existe na lista de entities | Baixa | `_normalize_extraction_subjects()` só reescreve subjects que estão no alias map. Subjects que não casam com nenhum alias passam inalterados. |
| Testes existentes quebram por mudança no schema | Nula | Campo tem default `[]`. Nenhum teste existente passa `aliases`. Pydantic preenche automaticamente. |
