# Spec: Living Entity Profiles

> From PRD: `.vibeflow/prds/living-entity-profiles.md`
> Generated: 2026-03-30

## Objective

Cada entidade passa a ter um perfil vivo (~100-300 tokens) que é input e output da extração informada, dando ao retrieval uma visão coerente por entidade sem depender de background jobs.

## Context

Hoje o `MemoryEntity` tem um campo `summary_text` que depende do background job `refresh_entity_summaries()` em `sleep_time.py`. No LoCoMo, esse job não rodou — todas as 15 entidades ficaram com summary=null. Mesmo quando roda, ele é lazy (top 10 por importance, refresh a cada 7 dias) e desconectado do write pipeline.

Com a Memory-Aware Extraction (spec anterior), o pipeline já faz alias lookup + pré-retrieval antes de extrair. O pré-retrieval busca top-K fatos por embedding similarity. Isso funciona, mas injeta fatos soltos — o LLM recebe pedaços desconexos em vez de uma visão coerente.

O perfil resolve ambos: substitui o pré-retrieval de fatos individuais por um texto compacto e coerente (mais barato, mais útil), e dá ao retrieval uma resposta pronta pra queries amplas ("Who is Caroline?").

## Dependencies

- `.vibeflow/specs/memory-aware-extraction.md` — Esta spec modifica o prompt e pipeline introduzidos na spec anterior. Deve ser implementada depois.

## Definition of Done

1. **`profile_text` existe no model** — `MemoryEntity` tem coluna `profile_text` (nullable Text) e `profile_refreshed_at` (nullable DateTime). Migration Alembic criada e funcional.
2. **Extração informada recebe perfis como contexto** — O prompt em `informed_extract.py` recebe `profile_text` das entidades conhecidas em vez de (ou além de) fatos individuais. Quando perfil existe, substitui o pré-retrieval de fatos; quando não existe, fallback pra fatos individuais.
3. **Extração informada retorna perfis atualizados** — O output JSON inclui campo `updated_profiles` (dict entity_name → string | null). O schema Pydantic valida esse campo.
4. **Pipeline persiste perfis** — `run_write_pipeline()` atualiza `profile_text` e `profile_refreshed_at` das entidades cujo perfil foi retornado pelo LLM, na mesma transação do upsert de fatos.
5. **Context compression usa perfis** — `compress_context()` injeta `profile_text` no hot tier quando a query envolve entidades com perfil populado. Queries amplas sobre uma entidade usam o perfil como contexto principal.
6. **Cold start cria perfil inicial** — Na primeira extração que toca uma entidade (profile_text=null), o LLM gera o perfil inicial a partir dos fatos extraídos. A partir da segunda mensagem, o perfil existente é injetado como contexto.
7. **Segue patterns do projeto** — Fail-safe: se `updated_profiles` vier malformado ou ausente, perfil existente é mantido inalterado. Pipeline não faz commit. Savepoints pra DB writes.

## Scope

### Arquivos modificados

- **`src/arandu/models.py`** — `MemoryEntity` ganha `profile_text: Mapped[str | None]` (nullable Text) e `profile_refreshed_at: Mapped[datetime | None]` (nullable DateTime).
- **`src/arandu/write/informed_extract.py`** — Prompt modificado: seção de contexto existente usa `profile_text` quando disponível, fallback pra top-K fatos quando não. Output schema ganha `updated_profiles: dict[str, str | None]`. Parser valida e extrai perfis atualizados.
- **`src/arandu/write/pipeline.py`** — Antes da extração: carregar `profile_text` das entidades via alias lookup. Após upsert: persistir perfis atualizados no `MemoryEntity`. Ambos dentro da mesma transação.
- **`src/arandu/read/context_compression.py`** — `compress_context()` recebe entidades com perfil. Se entidade do query tem `profile_text`, inclui no hot tier como bloco de contexto. Não substitui fatos — complementa.

### Arquivos novos

- **`alembic/versions/xxxx_add_entity_profile.py`** — Migration: ADD COLUMN `profile_text TEXT`, ADD COLUMN `profile_refreshed_at TIMESTAMP WITH TIME ZONE`.

### Testes

- **`tests/test_informed_extract.py`** — Estender com testes de profile input/output, cold start profile creation, fallback quando profile ausente.
- **`tests/test_context_compression.py`** — Testes pra injeção de perfil no hot tier.

## Anti-scope

- **Perfis estruturados com seções tipadas** — v0 é texto livre. O LLM gera texto corrido, não JSON estruturado com [Identity], [Relationships], etc.
- **Perfis pra entidades non-person** — v0 gera perfis pra TODAS as entidades tocadas pela extração (person, place, org, etc.), mas o prompt não tem instruções especializadas por tipo. Se o LLM gerar perfil pobre pra "place:beach", é aceitável.
- **Profile embedding separado** — `embedding_vec` do `MemoryEntity` continua sendo embedding do nome (usado pelo entity resolution fuzzy). Não substituir, não adicionar coluna extra. O perfil é usado como texto, não como vetor.
- **Profile history/versioning** — Não manter versões anteriores. Profile é overwritten a cada update. Histórico está nos fatos atômicos.
- **Mudanças no background job existente** — `refresh_entity_summaries()` e `summary_text` ficam intactos. Dois campos coexistem: `summary_text` (background job) e `profile_text` (write pipeline).
- **Profile-based retrieval signal** — Não criar novo signal de retrieval baseado em embedding do perfil. O perfil entra no contexto como texto estático.
- **Configuração de tamanho do perfil** — Guideline fixo no prompt (~100-300 tokens). Sem config exposta.

## Technical Decisions

### 1. Campo novo `profile_text` (não reutilizar `summary_text`)

**Escolha:** Adicionar `profile_text` como coluna separada de `summary_text`.

**Justificativa:** São gerados por mecanismos diferentes (`profile_text` pelo write pipeline, `summary_text` pelo background job), com cadências diferentes (a cada write vs. a cada 7 dias), e podem coexistir. Reutilizar `summary_text` criaria conflito: o background job sobrescreveria o perfil gerado pelo write pipeline.

**Trade-off:** Mais uma coluna nullable no model. Baixo custo. Clareza compensa.

### 2. Perfil substitui pré-retrieval de fatos (quando disponível)

**Escolha:** Quando `profile_text` existe pra uma entidade, o prompt da extração informada usa o perfil como contexto. Quando não existe (cold start), faz fallback pra top-K fatos por embedding similarity (comportamento da spec 1).

**Justificativa:** O perfil é mais compacto (~100-300 tokens vs ~200 tokens pra 10 fatos) e mais coerente (texto narrativo vs. lista de fatos soltos). O LLM extrai melhor quando o contexto é coerente.

**Trade-off:** O perfil pode estar stale (atualizado na última mensagem que tocou a entidade, não na corrente). Mitigação: o LLM vê a mensagem nova e pode corrigir/atualizar o perfil no output.

### 3. Perfil gerado dentro da mesma chamada LLM (custo marginal)

**Escolha:** O prompt da extração informada pede `updated_profiles` como campo adicional no output JSON. Não há chamada LLM extra.

**Justificativa:** O LLM já tem todo o contexto necessário (mensagem + perfil existente + fatos extraídos). Pedir o perfil atualizado é ~50-100 tokens extras no output. Custo marginal próximo de zero.

**Trade-off:** Output JSON mais complexo (mais um campo). Risco de parse errors marginalmente maior. Mitigação: `updated_profiles` é tratado como opcional — se ausente ou malformado, perfil existente é mantido.

### 4. `embedding_vec` não muda (fica como embedding do nome)

**Escolha:** Não substituir `embedding_vec` por embedding do perfil. Não adicionar coluna extra de embedding.

**Justificativa:** `embedding_vec` é usado pelo entity resolution fuzzy (Phase 2 em `entity_resolution.py`). Substituir por embedding do perfil poderia degradar a resolução de entidades (um perfil sobre "Caroline the LGBTQ advocate" não matcha tão bem contra a query "Caroline" quanto o embedding puro do nome). O risco não compensa o benefício no v0.

**Trade-off:** Perde a oportunidade de usar o perfil como signal de retrieval por embedding. Aceitável pra v0 — o perfil já é injetado como texto no context compression.

### 5. Profile injection no context compression: hot tier como bloco

**Escolha:** Quando a query envolve uma entidade com `profile_text`, o perfil é injetado como um bloco no hot tier do context compression, antes dos fatos individuais.

**Formato:**
```
[About Caroline]
Close friend of Mel. Active LGBTQ advocate, member of 'Connected LGBTQ Activists'.
Attended city pride parade recently. Uses art as therapeutic outlet. Interested in mental health.

[Memory: Caroline]
- Caroline attended a pride parade in her city last weekend (confidence: 0.95)
- Caroline joined an LGBTQ activist group called 'Connected LGBTQ Activists' (confidence: 0.90)
...
```

**Justificativa:** O perfil dá contexto amplo, os fatos dão evidência específica. Juntos, a resposta é mais completa do que qualquer um sozinho.

**Trade-off:** Consome budget de tokens do hot tier (~100-300 tokens por perfil). Com 2 entidades, são ~200-600 tokens dedicados a perfis, reduzindo espaço pra fatos. Aceitável porque o perfil agrega mais valor por token do que fatos individuais pra queries amplas.

## Applicable Patterns

- **Pipeline Orchestration** (`patterns/pipeline-orchestration.md`) — Perfis carregados e persistidos dentro de `run_write_pipeline()`. Timing medido. Trace registrado.
- **LLM Interaction** (`patterns/llm-interaction.md`) — `updated_profiles` parseado do mesmo JSON da extração informada. Fail-safe: campo ausente/malformado → manter perfil atual.
- **Data Access** (`patterns/data-access.md`) — Profile load: `SELECT profile_text FROM memory_entities WHERE canonical_key IN (...)`. Profile save: `UPDATE memory_entities SET profile_text = ..., profile_refreshed_at = now()`. Dentro de savepoint.
- **Error Handling** (`patterns/error-handling.md`) — Profile persistence é fail-safe: se falhar, fatos já foram persistidos normalmente. Log warning, não propagar.

## Risks

### 1. Perfil stale entre writes
**Risco:** Se uma entidade é tocada na mensagem 10, o perfil é atualizado. Nas mensagens 11-20 (que não tocam essa entidade), o perfil fica stale. Se a mensagem 21 toca a entidade de novo, o perfil injetado no prompt pode estar desatualizado.
**Mitigação:** O LLM recebe a mensagem nova + perfil antigo. Se os fatos extraídos adicionam informação, o LLM atualiza o perfil no output. A staleness é auto-corrigida a cada write que toca a entidade. Pra queries no read pipeline, o perfil é complementado por fatos atômicos recentes.

### 2. Perfil diverge dos fatos
**Risco:** O LLM pode gerar um perfil que diz algo que os fatos atômicos não suportam (alucinação). Como o perfil não é versionado, não há como detectar a divergência.
**Mitigação:** O perfil é instruído no prompt a ser factual e baseado nos fatos extraídos. Os fatos atômicos continuam sendo a fonte de verdade pra queries específicas. O perfil é auxiliar, não autoritativo. Se um consumidor precisa de accuracy, deve usar os fatos.

### 3. Output JSON mais complexo aumenta taxa de parse errors
**Risco:** Adicionar `updated_profiles` ao output (que já tem entities + facts + relations) pode fazer LLMs menores (DeepSeek V3) falharem no parse com mais frequência.
**Mitigação:** `updated_profiles` é tratado como campo OPCIONAL. Se ausente, perfil não é atualizado. O parse do resto do output (entities + facts + relations) não é afetado. O fallback pra extração cega (spec 1) cobre falhas totais.

### 4. Budget de tokens no context compression
**Risco:** Perfis de 2-3 entidades (200-600 tokens) podem consumir grande parte do budget de hot tier (default 1000 tokens = 50% de 2000). Menos espaço pra fatos individuais.
**Mitigação:** Se budget estiver apertado, truncar perfis pro hot tier (primeiros 150 tokens cada). Fatos individuais no warm tier compensam. Monitorar via trace.
