# Spec: Speaker-Aware Extraction

> Generated via /vibeflow:gen-spec on 2026-03-25
> PRD: `.vibeflow/prds/fix-speaker-vs-agent-identity.md`

## Objective

Eliminar a confusão entre speaker (quem fala) e agent (quem lembra). Hoje o sistema cria entidade "user" forçada + entidade nomeada duplicada + mirror facts. Depois desta mudança, `speaker_name` é obrigatório, pronomes resolvem pro speaker, e zero duplicação.

## Definition of Done

1. [ ] `MemoryClient.write()` aceita `speaker_name: str` como parâmetro obrigatório (positional após `message`). Chamada sem `speaker_name` levanta `TypeError`.
2. [ ] `ENTITY_SCAN_PROMPT` não menciona "user" nem força entidade "user". Inclui instrução `"The speaker is {speaker_name}"` e proíbe pronomes como entidades.
3. [ ] `FACT_EXTRACTION_PROMPT` usa `speaker_name` como subject para fatos sobre o speaker. Proíbe mirror facts explicitamente.
4. [ ] `_SELF_REFERENCES` renomeado para `_SPEAKER_PRONOUNS`. Phase 0 resolve pro speaker (`person:{slugified_speaker_name}`) em vez de `user:self`. Bloco de upsert de `user:self` removido.
5. [ ] Para mensagem `"Meu nome é Pedro, moro em Botafogo, trabalho na Stone"` com `speaker_name="Pedro"`: exatamente 3 entidades (Pedro, Botafogo/Rio, Stone), ~4-5 fatos com subject "Pedro", 0 entidade "user", 0 mirror facts.
6. [ ] Todos os testes existentes atualizados para passar `speaker_name`. Novos testes cobrem: speaker nomeado, pronomes → speaker, sem duplicação, sem mirror facts, conflito nome (alias).
7. [ ] `ruff check + ruff format --check + mypy + pytest` passam sem erros.

## Scope

### Arquivos a modificar (5 core + testes)

| Arquivo | Mudança |
|---------|---------|
| `src/arandu/client.py` | Adicionar `speaker_name: str` em `write()`. Propagar pra `run_write_pipeline()`. |
| `src/arandu/write/extract.py` | Reescrever 3 prompts. Propagar `speaker_name` em `_entity_scan()`, `_extract_facts_for_batch()`, `_extract_relations()`, `_multi_pass_extract()`, `extract_from_message()`. |
| `src/arandu/write/entity_resolution.py` | `_SELF_REFERENCES` → `_SPEAKER_PRONOUNS`. Phase 0 retorna `person:{speaker_slug}` em vez de `user:self`. Phase 0b (bare relationship terms) idem. Remover bloco `user:self` upsert no final de `resolve_entities()`. Propagar `speaker_name` nas assinaturas. |
| `src/arandu/write/pipeline.py` | Adicionar `speaker_name: str` em `run_write_pipeline()`. Passar pra `extract_from_message()` e `resolve_entities()`. |
| `tests/test_write_pipeline.py` | Atualizar todos os testes existentes. Adicionar novos testes. |
| `tests/test_entity_resolution.py` | Atualizar Phase 0 tests. Adicionar speaker resolution tests. |
| `tests/test_extract.py` | Se existir, atualizar. |

### Estimativa: ~150-200 linhas modificadas/adicionadas.

## Anti-scope

- **Não** mexer no read pipeline
- **Não** mexer no banco/migrations/models
- **Não** mexer em background jobs (clustering, consolidation, memify, sleep-time)
- **Não** implementar multi-speaker na mesma mensagem (1 speaker por write call)
- **Não** implementar memória do agent sobre si mesmo ("seu nome é Jarvis")
- **Não** fazer `speaker_name` opcional
- **Não** adicionar merge retroativo de entidades `user:self` existentes
- **Não** mexer no `upsert.py` ou `reconcile.py` (mirror facts são eliminados nos prompts, não no upsert)

## Technical Decisions

### TD-1: `speaker_name` é positional, obrigatório

```python
# client.py — nova assinatura
async def write(
    self,
    user_id: str,
    message: str,
    speaker_name: str,  # NEW — obrigatório
    source: str = "api",
    *,
    config_overrides: dict | None = None,
    dry_run: bool = False,
    recent_messages: list[str] | None = None,
    verbose: bool = False,
) -> WriteResult:
```

**Rationale:** V0, sem usuários. Sem backward compat. Obrigatório porque sem speaker_name o sistema não sabe quem fala.

### TD-2: Prompts reescritos com speaker_name dinâmico

Os prompts deixam de ser constants estáticos e passam a ser **templates** formatados com `speaker_name`.

```python
# extract.py — entity scan prompt (template)
ENTITY_SCAN_PROMPT_TEMPLATE = """You are a personal knowledge extractor.
Given a conversation message, identify ALL entities mentioned.

SPEAKER CONTEXT: The speaker of this message is "{speaker_name}".
All first-person pronouns (I, me, my, myself, eu, mim, meu, minha, yo, mi, je, moi, ich) refer to "{speaker_name}".

ENTITIES: Every person, place, organization, product, event, concept, pet, team, or named thing.
- Each entity has: name, type (free-form string), aliases (optional list)
- The speaker "{speaker_name}" MUST be included as an entity with type="person".
- NEVER create an entity called "user" — use "{speaker_name}" instead.
- NEVER create entities from pronouns (I, me, eu, etc.) — they all refer to "{speaker_name}".
- Include EVERY named entity, even if mentioned only once.
- IMPORTANT: When the same entity is referred to by multiple names (nickname + full name, abbreviation + full form), create ONE entity with the most complete/formal name as "name" and the other names in "aliases".
  Examples:
  - "my friend Guili (Guilherme Maturana)" → {{"name": "Guilherme Maturana", "type": "person", "aliases": ["Guili"]}}
  - "Stone (StoneCo)" → {{"name": "StoneCo", "type": "organization", "aliases": ["Stone"]}}
- Only group names when the message explicitly indicates they refer to the same entity.
- Pay careful attention to entity type classification. Cities, countries, and neighborhoods are "place", not "person".

Respond in JSON with key: entities (array of {{name, type, aliases}})."""


# extract.py — fact extraction prompt (template)
FACT_EXTRACTION_PROMPT_TEMPLATE = """You are a personal knowledge extractor.
Given a conversation message and a list of entities, extract ALL atomic facts.

SPEAKER CONTEXT: The speaker of this message is "{speaker_name}".
All first-person pronouns (I, me, my, eu, mim, meu) refer to "{speaker_name}".

Rules:
- ONE fact = ONE piece of information. Never combine multiple pieces into one fact.
- Each fact has: subject (entity name), fact (natural language sentence), confidence (0.0-1.0)
- Use "{speaker_name}" as subject for facts about the speaker. NEVER use "user" as subject.
- SELF-CONTAINED: The fact text MUST include the subject's name. "Pedro is 34 years old", NOT "is 34 years old".
- UNAMBIGUOUS: Each fact must include enough context to be understood without the original message.
- Confidence: 0.95 (explicitly stated), 0.80 (strongly implied), 0.60 (weakly implied).

NO DUPLICATE INFORMATION: Each piece of information MUST appear in exactly ONE fact.
- If entity A did something involving entity B, the fact belongs to the ACTOR or POSSESSOR.
- "Pedro works at Stone" → subject is "{speaker_name}". Do NOT also create "Stone employs Pedro".
- NEVER create mirror facts (the same information from both perspectives). Pick ONE subject — the person, not the place/org.
- NEVER extract the same information from both active and passive perspective.
- When in doubt, use the grammatical subject of the original sentence.

Respond in JSON with key: facts (array of {{subject, fact, confidence}})."""


# extract.py — relation extraction prompt (template)
RELATION_EXTRACTION_PROMPT_TEMPLATE = """You are a personal knowledge extractor.
Given a conversation message and a list of entities, extract ALL relationships between them.

SPEAKER CONTEXT: The speaker of this message is "{speaker_name}".

Each relation has: source, target, rel_type
- Use a short, descriptive snake_case type (e.g., works_at, lives_in, spouse_of, member_of).
- Use "{speaker_name}" (not "user") when the speaker is part of a relationship.

Rules:
- Extract EVERY relationship mentioned or strongly implied.
- "A works at B" → source="A", target="B", rel_type="works_at"
- If the message implies a chain (A → B → C), extract each link separately.

Respond in JSON with key: relations (array of {{source, target, rel_type}})."""
```

**Rationale:** Templates permitem injetar `speaker_name` sem perder o pattern de prompts como constants. `.format(speaker_name=speaker_name)` no ponto de uso.

### TD-3: Entity resolution — Phase 0 resolve pro speaker

```python
# entity_resolution.py

# Renomear _SELF_REFERENCES → _SPEAKER_PRONOUNS (mesma lista de pronomes)
_SPEAKER_PRONOUNS = frozenset({
    "user", "me", "i", "myself",  # English
    "eu", "mim",                   # Portuguese
    "yo", "mi",                    # Spanish
    "je", "moi",                   # French
    "ich",                         # German
})

async def _resolve_single(
    session, user_id, entity, existing_entities, alias_cache,
    llm, embeddings=None, *, fuzzy_threshold=0.85,
    enable_llm_resolution=True,
    speaker_name: str,  # NEW
) -> ResolvedEntity:
    clean_name, hint = _parse_subject_hint(entity.name)
    entity_type = entity.type.lower().strip() if entity.type else "other"

    if _hint_implies_person(hint):
        entity_type = "person"

    # Phase 0: Speaker pronouns → resolve to speaker entity
    name_lower = clean_name.lower().strip()
    if name_lower in _SPEAKER_PRONOUNS:
        speaker_slug = _slugify(speaker_name)
        return ResolvedEntity(
            entity_type="person",
            entity_key=f"person:{speaker_slug}",
            display_name=speaker_name,
            is_new=False,  # will be created if needed in entity upsert step
            resolution_method="speaker",
        )

    # Phase 0b: Bare relationship terms → also speaker
    name_normalized = _normalize_name(clean_name)
    if name_normalized in _RELATIONSHIP_HINTS:
        speaker_slug = _slugify(speaker_name)
        return ResolvedEntity(
            entity_type="person",
            entity_key=f"person:{speaker_slug}",
            display_name=speaker_name,
            is_new=False,
            resolution_method="speaker_relationship",
        )

    # Phase 1, 1.5, 2, 3: unchanged
    # ...
```

**Rationale:** Pronomes agora resolvem pra entidade do speaker real, não pra `user:self` genérico. Entity resolution 3-phase (exact → fuzzy → LLM) fica intacta.

### TD-4: Remover bloco `user:self` upsert

No final de `resolve_entities()`, o bloco que garante `user:self` existe na tabela é removido:

```python
# REMOVER este bloco:
if any(e.entity_key == "user:self" for e in entity_map.values()):
    from arandu.write.entity_helpers import create_or_update_entity
    await create_or_update_entity(session, user_id, "user:self", ...)
```

**Rationale:** `user:self` não existe mais. A entidade do speaker é criada normalmente pelo fluxo padrão de entity resolution → entity upsert no pipeline.

### TD-5: Edge case — conflito `speaker_name` vs nome na mensagem

`speaker_name="Pedro"`, mensagem diz "Meu nome é João".

O prompt já instrui: pronomes → Pedro. "João" aparece como nome na mensagem. A LLM pode:
1. Criar "João" como alias de Pedro (se entender que é a mesma pessoa)
2. Criar "João" como entidade separada (se entender que é outra pessoa)

Entity resolution tratará: se "João" e "Pedro" são a mesma pessoa, fuzzy/LLM match resolve. Se são diferentes, ficam separados. **Não precisa de lógica especial** — o pipeline existente já lida com isso.

### TD-6: Propagação de `speaker_name` pela cadeia

```
MemoryClient.write(speaker_name=)
  → run_write_pipeline(speaker_name=)
    → extract_from_message(speaker_name=)
      → _multi_pass_extract(speaker_name=)
        → _entity_scan(speaker_name=)          # formata ENTITY_SCAN_PROMPT_TEMPLATE
        → _extract_facts_for_batch(speaker_name=)  # formata FACT_EXTRACTION_PROMPT_TEMPLATE
        → _extract_relations(speaker_name=)    # formata RELATION_EXTRACTION_PROMPT_TEMPLATE
    → resolve_entities(speaker_name=)
      → _resolve_single(speaker_name=)         # Phase 0 usa speaker_name
```

## Applicable Patterns

### Pipeline Orchestration
- `run_write_pipeline()` recebe `speaker_name` como parâmetro explícito (não state object)
- Stages recebem `speaker_name` como argumento direto
- Pipeline continua: extract → resolve → reconcile → upsert (sem mudança de ordem)

### LLM Interaction
- Prompts continuam como module-level constants (agora templates com `{speaker_name}`)
- `temperature=0`, `response_format={"type": "json_object"}`
- Fail-safe: on error → return empty results
- `.format(speaker_name=speaker_name)` no ponto de uso (dentro de cada `_entity_scan`, etc.)

### Testing
- `FakeLLMProvider` com canned responses — atualizar responses pra usar `speaker_name` em vez de "user"
- Novos testes:
  - `test_extract_uses_speaker_name`: verifica que entidades/fatos usam speaker_name
  - `test_no_user_entity_created`: verifica que "user" não aparece nas entidades
  - `test_pronouns_resolve_to_speaker`: verifica Phase 0 com speaker_name
  - `test_no_mirror_facts`: verifica que org/place não tem fatos sobre o speaker
  - `test_speaker_name_required`: verifica TypeError sem speaker_name

### Error Handling
- Se extraction falhar → return empty (fail-safe, sem mudança)
- `speaker_name` vazio/whitespace → tratar como erro no `write()` (raise `ValueError`)

## Implementation Order

1. **`extract.py`** — Reescrever prompts como templates. Adicionar `speaker_name` em todas as funções. (~60 linhas)
2. **`entity_resolution.py`** — Renomear `_SELF_REFERENCES`, mudar Phase 0/0b, remover `user:self` upsert, propagar `speaker_name`. (~40 linhas)
3. **`pipeline.py`** — Adicionar `speaker_name` em `run_write_pipeline()`, passar pra extract e resolve. (~10 linhas)
4. **`client.py`** — Adicionar `speaker_name` em `write()`, passar pro pipeline. Validar não-vazio. (~10 linhas)
5. **Testes** — Atualizar existentes, adicionar novos. (~80 linhas)
