# PRIVIA INTELIGENTE — Registro de Comandos e Respostas

> Log operacional do projeto "agente IA no Privia". Atualizado ao longo da sessão.
> Ambiente: Windows PowerShell 5.1 + Docker (container `php74_apache` = PHP 7.4.33 + Composer 2.7.7; `mysql57` = MySQL 5.7).

---

## Sumário da linha do tempo

| # | Quando | O que | Resultado |
|---|---|---|---|
| 1 | 09/09 | `Get-ChildItem -Force` | Projeto Yii2 identificado (controllers/, models/, modules/, config/, vendor/…) |
| 2 | 09/09 | `composer --version` | Composer `não` no PATH |
| 3 | 09/09 | `php --version` | PHP `não` no PATH |
| 4 | 09/09 | busca `where /R` | Achei `composer.phar` em `C:\projetos\apps\accia` e `eletrobras-api`, sem `php.exe` |
| 5 | 09/09 | `docker version` | Docker 29.7.2 funcionando |
| 6 | 09/09 | `docker ps -a` | Containers: `php74_apache` (projetos-webserver), `mysql57`, etc. |
| 7 | 09/09 | `docker exec php74_apache php -v` | PHP **7.4.33** |
| 8 | 09/09 | `docker exec php74_apache composer --version` | Composer **2.7.7** |
| 9 | 09/09 | `docker inspect php74_apache` (mounts) | `C:\projetos\apps` → `/var/www/html`; `C:\projetos\apache\vhosts` → `/etc/apache2/sites-enabled` |
| 10 | 09/09 | `docker exec -w /var/www/html/privia-inteligente php74_apache composer install` | **Sucesso**: 199 pacotes instalados, autoload gerado, `yii\composer\Installer::postInstall` ok |
| 11 | 09/09 | codebase-memory `list_projects` | Projeto indexado: `privia-app` (root `C:/projetos/apps/privia-app`) |
| 12 | 09/09 | `Test-Path privia-app` + git | `privia-inteligente` e `privia-app` = **mesmo repo** `krinomcode/privia-app`, mesmo HEAD `c7572ad` |
| 13 | 09/09 | leitura `config/web.php`, `db.php`, `params.php`, `console.php`, `.env` | Arquitetura Yii2 2.0.16.1, DB `mlabs`, RBAC mdm-admin, JWT, sessão em DB |
| 14 | 09/09 | `glob` controllers/models/modules | ~32 controllers, ~90 models, 7 módulos (performance, risk, financial, operational, contract, task, api) |
| 15 | 09/09 | `docker exec mysql57 mysql … SHOW DATABASES` | `information_schema, eletrobras, meu_banco, mlabs, mysql, performance_schema, psec_vialgpd, sys` |
| 16 | 09/09 | 3 subagentes `explore` (auth/tenant, módulos, frontend) | Ver "Descobertas" abaixo |
| 17 | 09/09 | `docker exec mysql57 mysql … TABLES` | ~230 tabelas no `mlabs` (coleta_alternativa 225K, inventario_resposta 7,1M, risco_vertical 1,5M) |
| 18 | 09/09 | `ollama list` | Modelo local: **qwen2.5:3b** (capabilities `completion`,`tools`) |
| 19 | 09/09 | `SHOW CREATE TABLE bot*` + grep código | Tabelas `bot*` **órfãs** (0 linhas, sem código PHP) |
| 20 | 09/09 | `curl host.docker.internal:11434/api/tags` (dentro do container) | **Ollama acessível** do container → `http://host.docker.internal:11434` |
| 21 | 09/09 | `Invoke-WebRequest api.openai.com` | Internet OK; exige chave (HTTP 401) |
| 22 | 09/09 | leitura de `Coleta.php` (geraPontuacao/getValorGlobal), `BaseModel.php` (faixas), `Contexto.php` (getLgpdPontuacao) | Cadeia de pontuação confirmada |
| 23 | 09/09 | escrita `docs/agente/diagnostico-privia.md` | Diagnóstico da FASE 1 (arquitetura, tenant, score, regras, catálogo DB, planos) |
| 24 | 09/09 | Question do usuário (backend LLM) | Resposta: **"Híbrido"** (padrão Ollama local + env p/ OpenAI-compatível) |
| 25 | 09/09 | `SHOW COLUMNS` de `user_has_resource`, `profile`, `cliente`, `contexto`, `coleta`, `coleta_estrutura`, `coleta_indicador`, `coleta_alternativa`, `vertical` | Schema do tenant e do motor de score |
| 26 | 09/09 | criação de `projeto.md` | Este arquivo |
| 27 | 09/09 | escrita do módulo `agent` (Etapa 2-9) | ~25 arquivos PHP/JS/CSS + schema SQL, todos com `php -l` OK |
| 28 | 09/09 | `php yii agent/index` | indexou **1202 arquivos** → +2465 registros `agent_knowledge` |
| 29 | 09/09 | `php yii agent/schema` | +2 registros (tabelas/colunas via information_schema) |
| 30 | 09/09 | `php yii agent/search "Coleta"` / `--tipo=` | buscas funcionando (classes, rotas, tabelas) |
| 31 | 09/09 | `php yii agent/cadeia --coleta=3048` | cadeia reconstruída: **67 = (30×97 + 50×68 + 20×20)/100 = 67,1 → 67** (bateu) |
| 32 | 09/09 | `php yii agent/pergunta ... --coleta=3048` (Ollama qwen2.5:3b) | bug `properties: {}` → HTTP 400 no Ollama (corrigido); execução lenta (~120-160s) e detalhes com alucinação |
| 33 | 09/09 | renovação de tool names (`context.get`→`context_get`, etc.) | exigência do DeepSeek: pattern `^[a-zA-Z0-9_-]+$` |
| 34 | 09/09 | usuário forneceu chave DeepSeek; `curl api.deepseek.com/v1/models` | modelos `deepseek-v4-flash`/`deepseek-v4-pro`; configurado `AGENT_LLM_MODEL=deepseek-v4-pro` no `.env` |
| 35 | 09/09 | `php yii agent/pergunta "Por que a nota é 67?...explique SI=20" --coleta=3048` (DeepSeek) | **resposta correta e rastreável em 9 iterações/40s**; dados conferidos contra o banco |
| 36 | 09/09 | `docker exec php74_apache apache2ctl graceful` | **Causa do HTTP 404 resolvida**: Apache com config antiga (confs de 18:49–18:50 +0000 vs master de 10:56Z sem reload) → `privia-inteligente.localhost` caía no vhost default `accia`; reload corrigiu `/agent/chat/*` (200 JSON de guest) |
| 37 | 09/09 | `AgentDb::validarSelect($sql, $internal=true)` + `queryAll/queryOne` c/ flag | Bug A: `TenantGuard::fks()` usava `information_schema.COLUMNS` bloqueado pelo FORBIDDEN → agora consultas internas passam por `$internal=true` sem liberar as tools do LLM |
| 38 | 09/09 | `AgentWidget.php` `use app\modules\agent\assets\AgentAsset` | Bug B: classe `AgentAsset` estava em `assets/` e o widget referenciava no namespace `widgets/` → registro correto |
| 39 | 09/09 | `TenantGuard::resolveClienteId/caminhoAteCliente` | Bug C (3 falhas): `isset()` de comClienteId aceitava valor `false` (tabela referenciada sem `cliente_id`); SELECT usava nome base sombreado por alias do JOIN; alias de join com `count()` constante repetido → reescrito com último alias + contador |
| 40 | 09/09 | `scripts/teste-isolamento.ps1` (curl.exe + cookie jars) | **6/6 testes passaram**: ceon bypass; clia(339) vê 3048/454 e bloqueia 3029/683; clib(328) vê 3029/683 e bloqueia 3048/454; E2E chat clia coleta 3048 → resposta contém "67" (2 iterações: context_get, coleta_cadeia_nota) |
| 41 | 09/09 | widget no navegador ("Falha na requisição.") | Causa: resposta do LLM com **emoji (4 bytes)** → `agent_conversa.content` era `utf8` (mb3) + conexão `charset=utf8` → SQLSTATE 1366. Corrigido: tabelas do agente convertidas p/ **utf8mb4**, nova conexão dedicada `dbAgent` (utf8mb4) usada por `AgentConversa`/`AgentFeedback`, `AgentDb` com `charset=utf8mb4`; regressão 6/6 PASS e emoji `F09F988A` íntegro no banco |
| 42 | 09/09 | prompt de prioridade + retry de conteúdo vazio | Agente repetia a resposta antiga **errada** do histórico (coerência com a própria mensagem anterior). Corrigido: systemPrompt declara prioridade das REGRAS/ACESSO sobre respostas antigas do assistente; `Agent::run` retry 1x em resposta vazia + fallback amigável; limite de passos agora lista as tools usadas. Validado: ceon→"acesso total, 72 clientes"; clia→"somente Gicon Contabilidade (id 339)" |
| 43 | 09/09 | soft-delete ensinado ao agente (status !=0 / active=1) | Agente listou clientes **inativos** (soft-delete do Privia). Criado `SoftDeleteRegistry` (mapa tabela→coluna via information_schema + overrides `cm_contract/cm_provider/cm_additive`→`active`, pois `status` é workflow); `db_select` filtra por padrão (`status != 0`, `active = 1`, `excluido = 0`) com `incluir_inativos=true` como exceção; `db_schema` anota a coluna; regra nº10 no systemPrompt; 15 regras em `agent_knowledge` (`seed_softdelete.php`). E2E: ceon respondeu **"60 clientes ativos"** (antes 72 c/ inativos) |
| 44 | 09/09 | widget mostra o histórico ao abrir | Novo `GET /agent/chat/historico` (últimas 30, ordem cronológica) + JS do widget carrega e renderiza a conversa na primeira abertura, removendo as boas-vindas quando há histórico. Validado: JSON `ok=true` com 6 mensagens em ordem user→assistant |
| 45 | 09/09 | histórico não apareceva no widget | **Cache de asset do Yii2**: `/assets/c9b47990/js/agent-widget.js` publicado era a versão antiga (sem `carregaHistorico`) — `appendTimestamp` muda o `?v=` mas o Yii não republicou o arquivo mudo. Removido o diretório stale → republicado (4957 B, `?v=` novo) e página validada |
| 46 | 09/09 | **gancho de fine-tuning** | `seed_feedback_qa.php` (idempotente) registra 4 pares pergunta→`resposta_ideal` validados E2E (clientes c/ tenant+soft-delete, "Quem sou eu?", nota 67 com decomposição, inclusão de inativos sob pedido explícito); `php yii agent/export-feedback` → `data/agent-feedback.jsonl` com **5 exemplos** (UTF-8, JSONL). `php yii agent/index` → Total `agent_knowledge` = **2554** (nova classe `SoftDeleteRegistry` indexada); lint geral do módulo sem erros |
| 47 | 09/09 | **rate-limit por usuário** | `actionSend` bloqueia com **HTTP 429** antes do custo de LLM quando `contagensRecentes()` (> mensagens `role=user` em `agent_conversa` na janela) ≥ limite; env `AGENT_RATE_LIMIT_MAX` (default 10) e `AGENT_RATE_LIMIT_WINDOW` (default 60s). Validado E2E com `MAX=1`: 1ª msg `200 ok=true`, 2ª msg `429 ok=false "Muitas perguntas em pouco tempo..."`; `.env` restaurado e mensagens de teste removidas |
| 48 | 09/09 | **suíte unit verde (Codeception)** | `config/test_db.php` passou a apontar p/ `mysql57` + banco isolado `ceon_tests` (env com fallback); criado/seedado `ceon_tests.user_ceon` (admin id 100, demo) seguindo o contrato do template; teste de access-token adaptado ao **JWT** real do app (`lcobucci/jwt` 3.3.3, claim `uid`, chave de `params.jwt`); corrigido bug latente: `ContactForm` usava `params['senderEmail']/['senderName']` inexistentes (quebraria envio real) → adicionados ao `params.php`. Resultado: **`vendor/bin/codecept run unit` → OK (8 tests, 30 assertions)** |
| 49 | 09/09 | **histórico: últimas 20 + lazy-load + datas** | `actionHistorico` agora pagina por cursor `id_before` (limite 20) e retorna `tem_mais`; `AgentConversa::pagina()`/`existeAntesDe()`. Widget JS: ao rolar para cima carrega página mais antiga (prepend preservando scroll), mensagens com data/hora formatada (`ag-msg-data`; hoje = HH:MM, ano atual = dd/mm HH:MM, senão dd/mm/aaaa HH:MM). **Separador de dia**: quando o dia muda entre mensagens entra `──── dd/mm/aaaa ────` (cinza, centralizado), criado também na fronteira do lazy-load (divisor antigo no topo é removido e recriado com a data correta). Asset republicado (JS 10593 B; CSS com `.ag-divisor-dia`), `node --check` OK. Validado E2E: 20 na pág.1 + cursor traz os antigos + `criado_em`/`id` presentes; regressão isolamento **6/6 PASS** |
| 50 | 09/09 | **fix lazy-load (nó de texto) + spinner de loading** | `carregarAntigos` usava `body.firstChild` (nó de TEXTO com o whitespace do markup) → `ref.className.indexOf(...)` lançava `TypeError` engolido pelo `.catch`: requisição `id_before` aparecia no inspecionar mas nada era adicionado. Corrigido com `body.children[0]` + `getAttribute('class')`. Adicionado **spinner** (`.ag-loading` + `.ag-spinner`, keyframes `ag-girar`) no carregamento inicial (com "Carregando histórico…" na barra de status) e no lazy-load; removido em sucesso/erro (falha mantém boas-vindas). Validado em jsdom com o DOM real (render 7 msgs, lazy 7+4 c/ divisor, `spinners remanescentes: 0`) e asset republicado (JS 10597 B) |
| 51 | 09/09 | **agente responde "quem e quando excluiu" (auditoria de exclusão)** | Investiguei a trilha: `coleta` só guarda `status` (soft-delete); **quem/quando está na tabela `log`** (`Base::loga` grava mensagem "Excluiu Coleta", `user_id`, `created_at`, `url`/`get` com o id, `ip`). Coleta 17/03/2026 IBDCNI (Hospital Rogacionista Evangélico, contexto 612): **3018–3024 excluídas por Gledson Cardoso (user_id 4)** em 17/03/2026 (lote 10:58:52–10:59:07 e lote 11:23:06–11:23:16, IP 104.28.163.2); **3025 segue ativa**. Ensinei o agente por 2 vias: regra nº12 no systemPrompt (`Agent.php`) + seed idempotente `seed_auditoria_exclusao.php` (2 regras `tipo=regra/entidade=exclusao_auditoria`). **E2E validado** (`agente.ceon`): resposta correta com tabela por coleta e resumo dos 2 lotes, 8 iterações (`context_get, db_schema, db_select`); mensagens de teste removidas do histórico; regressão isolamento **6/6 PASS** |
| 52 | 09/10 | **agente gera documentos (Excel/PDF/Word/PNG)** | Nova ferramenta `gerar_arquivo` + `ArquivosService` (xlsx PhpSpreadsheet c/ anti formula-injection; pdf mPDF; docx OOXML mínimo via ZipArchive; png GD c/ fallback bitmap sem FreeType); arquivos em `runtime/agent_arquivos/{user}/{token}` (TTL 24h) baixados por `/agent/chat/arquivo` autenticado; regra 13 + enforcement (pedido de arquivo sem `gerar_arquivo` ⇒ 1 correção forçada); anexos no JSON de `/send` e chips `.ag-anexo` no chat. E2E real gerou `usuarios_sistema.xlsx` (43 linhas) em 21.9s/7 iter; download com md5==runtime e visitante 302; jsdom widget+tela cheia 100% verdes |
| 53 | 09/10 | **refino da geração de documentos (feedback do usuário)** | 3 ajustes: (1) **dados sempre completos** — `ConsultaSegura::selecionar` (SELECT seguro único p/ db_select e arquivos) com parâmetro `consulta` buscando TODOS os registros (limite cheio 500 p/ arquivos, 200 p/ db_select) + **join seguro** de 1 tabela-whitelist (`auth_assignment`/`auth_item`) para função/cargo inexistente em `user_ceon`; (2) **nenhuma URL no texto** — regra 13 reescrita (gera completo, nunca escreve link) + scrub defensivo `/agent/chat/arquivo` no `agent-chat-core.js`; (3) **download só dentro da conversa** — coluna `agent_conversa.anexos` (JSON token/nome/formato/tamanho) gravada no `actionSend`, `/agent/chat/arquivo?t&id=<resposta_id>` exige id da própria mensagem assistant + token cadastrado nela => sem id 400, id/token inválido 404, visitante 302. E2E real: "excel com nome, e-mail, CPF e função" → `lista_de_usuarios.xlsx` com **411 registros** (dados completos, função via join), resposta sem URL (só chip), download md5==runtime, testes de autorização 400/404/302 coerentes; linhas de teste do 1332 removidas |
| 54 | 09/10 | **resposta bonita + URL de download nunca visível** | 2 ajustes pós-teste real: (1) **texto do agente com markdown-lite seguro**: `agent-chat-core.js` renderiza por DOM (sem innerHTML/XSS) **tabelas markdown** `.ag-tabela` (thead estilizado, zebra, negrito nos valores), cabeçalhos `#`/`##`/`###`, listas `- /1.` e parágrafos; (2) **URL nunca mais copiável**: o scrub agora remove markdown-links e URLs **relativas E absolutas** (`http(s)://qualquer-host/agent/chat/arquivo?...` — caso real `http://privia-inteligente.localhost/agent/chat/arquivo?t=…&id=172`), regra 13 + `instrucao` da ferramenta reforçadas ("nem relativa, nem com domínio completo") e **enforcer novo**: se a resposta final cita `/agent/chat/arquivo` havendo anexo, `Agent::run` força 1 correção para remover a URL. jsdom reproduce5 c/ tabela bonita + URL absoluto removido (15 OK; reproduce3/4 verdes) e E2E real: "gere uma imagem do perfil de desempenho com as 3 últimas coletas" → anexos (xlsx 411 linhas + png), **SEM-URL-NO-TEXTO**, download com `id` 200; assets republicados (md5==fonte); linhas de teste removidas |
| 55 | 09/10 | **download só pelo clique no artefato + tabela à prova de IA** | 2 ajustes sobre feedback real: (1) **download por POST+CSRF apenas** — `actionArquivo` deixa de aceitar GET (`MethodNotAllowed 405`), exige `isPost` + CSRF; o chip `.ag-anexo` (sem `target`/`rel`) vira botão que faz `fetch POST` c/ `X-CSRF-Token` → `blob` → `<a download>` no lugar de navegar/abrir aba; abrir/copiar a URL em outra guia ⇒ 405, POST sem CSRF ⇒ 400; (2) **renderizador à prova de IA desleixada** — regra 14 no systemPrompt ("tabela só com pipes `|` + linha `|---|---|`; texto corrido nunca em célula; separa `###` em linha própria") e `agent-chat-core.js` ganhou `quebraCabecalhos` (quebra `###` grudado no fim de uma frase), detecção de **tabela em TAB** (linhas com Tab viram `<table>` mesmo sem pipes/separador) e ramo p/ **cabeçalho grudado** (`### Título<TAB>Col A…` vira h3 + tabela). jsdom reproduce5 (20 OK): resposta reproduzindo o bug real (introdução+`###` na mesma linha, TSV sem separador, URL absoluta no texto) → parágrafo+h3+tabela renderizados, scrub remove URL, chip dispara POST c/ CSRF e baixa blob `download`; E2E real 2x verdes: excel 411 linhas SEM-URL, GET direto ⇒ 405, POST autenticado ⇒ 200 md5==runtime, matriz 400/404/302, imagem SEM-URL-no-texto, assets md5==fonte, resíduos 1332 zerados |
| 56 | 09/10 | **agente entende o Perfil de Desempenho (linhas) — tela de contexto mapeada** | Premissa: sem documentação, o agente devia "andar pelas telas" e gravar memória persistente. Mapeei `/performance/contexto/view` (`lgpdv2.php`; `view.php` é legado/`actionViewOld`): Perfil de Desempenho, Áreas de Preocupação (colunas c/ drilldown Valor Global→PVF→PVE→Indicador), Grupo de Indicadores (contagem), Evolução (linha do valor global), Nível de proteção (gauge), Criticidade (ROPA), Risco, Técnica. Conceitos validados com o usuário: **Perfil de Desempenho = gráfico de LINHAS** (X=indicadores da coleta referência por `ordem_estruturada`, uma série por coleta, Y=0-100) — o bug real era a IA gerar **barras de PVF**; **PVF = primeiros filhos do vértice cujo pai tem tipo "V"**; **coleta = espelho da Vertical** (`coleta_estrutura/coleta_indicador/coleta_alternativa` formam árvore; mudam entre coletas); faixas = `Base::faixasPadrao()` (hoje 20/80 fixos no controller). Entregue: **memória persistente** `agent_knowledge` 9 seeds (tipo `tela`/`regra`, confiança 100, fonte CURADORIA, rotas `performance/contexto/view`), **regra 15** no systemPrompt (gráficos por conceito + receita do perfil), `db_select` **não tem JOIN** (motivo do estouro: modelo lia PVE a PVE) ⇒ **nova ferramenta `perfil_desempenho`** (últimas N coletas do contexto validado, alinhadas por nome à referência, devolve `categorias`+`series`+`valor_global` prontas) e `AGENT_MAX_ITERATIONS` 7→12 (geração de docs precisa >7 passos: 7 iterações + 1 síntese). **E2E real** (`agente.ceon` via HTTP c/ `curl.exe` — stack HTTP do PS 5.1 fecha conexão neste Apache): "gere a imagem do perfil de desempenho das ultimas 3 coletas" → **3 iterações, `perfil_desempenho`+`gerar_arquivo`, PNG (7072 B) com linhas e séries corretas (67/64/35 valor global)**, contexto 454 (3048/2579/2028 — não as globais). Observações p/ próximas telas: hash de `user_ceon`/agente.* restaurado original; senha continua `Privia@Agente2026` |

---

## Detalhamento por etapa

### Comandos executados

#### 1) Inspeção inicial
```
Get-ChildItem -Force
composer --version        -> 'composer' não reconhecido
php --version             -> 'php' não reconhecido
glob composer.phar        -> vazio
where.exe /R C:\ php.exe  -> vazio
where.exe /R C:\ composer*-> C:\projetos\apps\accia\composer.phar | C:\projetos\apps\eletrobras-api\composer.phar
```

#### 2) Ambiente Docker (usado para rodar PHP/Composer/MySQL)
```
docker version                     -> Server 29.7.2
docker ps -a                       -> php74_apache (up), mysql57 (up), canal-denuncia, consents, etc.
docker exec php74_apache php -v    -> PHP 7.4.33 (NTS)
docker exec php74_apache composer --version -> Composer 2.7.7
docker inspect php74_apache --format '{{range .Mounts}}...{{end}}'
   -> SOURCE=C:\projetos\apps    DEST=/var/www/html
   -> SOURCE=C:\projetos\apache\vhosts DEST=/etc/apache2/sites-enabled
```

#### 3) Instalação das dependências (composer) — executado
```
docker exec -w /var/www/html/privia-inteligente php74_apache composer install --no-interaction
```
**Resposta (resumo):** 199 instalações a partir do `composer.lock`; downloads + extração ok; avisos de pacotes abandonados (swiftmailer, phpexcel, others); Geração de autoload ok; script `yii\composer\Installer::postInstall` executado sem erro.

#### 4) Versionamento / equivalência de diretórios
```
Test-Path C:\projetos\apps\privia-app   -> True
git -C ...\privia-inteligente remote -v -> origin https://github.com/krinomcode/privia-app.git
git -C ...\privia-app remote -v         -> idem
rev-parse HEAD                          -> c7572ade1c... (ambos)
vendor/autoload.php                     -> existe nos dois
```
**Conclusão:** `privia-inteligente` é um clone do mesmo repositório (gráfico indexado em `privia-app` é válido).

#### 5) Banco de dados
```
docker exec mysql57 mysql -uroot -prootpassword -N -e "SHOW DATABASES;"
docker exec mysql57 mysql -uroot -prootpassword -e "USE mlabs; <SHOW CREATE TABLE / SHOW COLUMNS ...>"
```
**Respostas-chave**
- DB principal: **`mlabs`** (`.env`: APP_DATABASE_DB=mlabs, host `mysql57`, user root/rootpassword).
- `user_has_resource(id, user_id, role, resource_id, param, value, extra)` → base do tenant.
- `profile(user_id PK, … , cliente_id)` → vínculo usuário→Cliente.
- `cliente(id, nome, razao_social, email, cpf_cnpj, taxa_substituicao_risco, sigla, escritorio, …)`.
- `contexto(id, …, cliente_id, lft/rgt/lvl [Nested Sets], pontuacao, …, natureza)`.
- `coleta(id, nome, projeto_id, vertical_id, status, contexto_id, data_coleta, created_by, …)`.
- `coleta_estrutura(id, cod_pai, tipo_estrutura, coleta_id, taxa_substituicao, pontuacao, status)`.
- `coleta_indicador(id, nome, estrutura_id, tipo, taxa_substituicao, pontuacao, vertical_indicador_id, …)`.
- `coleta_alternativa(id, nome, indicador_id, peso, nivel_esforco, nivel_referencia, selecionado, …)`.
- `vertical(id, nome, tipo, regra, origem_dados, …)` (template).

#### 6) Modelos locais de IA
```
ollama list
```
**Resposta:** `qwen2.5:3b` (1.9 GB, contexto 32768, capabilities: completion + **tools**).

#### 7) Conectividade do container → LLM
```
docker exec php74_apache curl -s -m 5 http://host.docker.internal:11434/api/tags
```
**Resposta:** JSON com o modelo `qwen2.5:3b` → **Ollama acessível** via `host.docker.internal:11434`.

```
Invoke-WebRequest https://api.openai.com/v1/models (HEAD)
```
**Resposta:** HTTP 401 — internet OK; API externa precisa de chave.

#### 8) tabelas bot*
```
SHOW CREATE TABLE bot / bot_acesso / bot_agendamento
grep "class Bot|bot_acesso|..." *.php -> (nada)
```
**Resposta:** tabelas órfãs, sem código; **não** servem de base para chat.

#### 9) Fontes do motor de score (lidas e citadas)
```
modules/performance/models/Coleta.php:280-290 (getValorGlobal)
modules/performance/models/Coleta.php:1072-1248 (geraPontuacao)
models/BaseModel.php:129-134 (cores e faixas 20/80)
modules/performance/models/Contexto.php:3182-3198 (getLgpdPontuacao)
```

---

## Descobertas (síntese das respostas dos subagentes)

### Auth / identidade / tenant / RBAC
- Identidade: `user_ceon.id`, login por **CPF** (`username`); convidado = `-2`.
- Tenant: `user_ceon.organization` (OAB, texto) + **Cliente** (escritório) via `profile.cliente_id` ou `user_has_resource` (papel `C-Performance - Nível 1 - Cliente Gestor`).
- Super-tenant: papéis `Ceon` / `Admin` / `CeonFinanceiro` (pulos nos filtros `filterMy*`).
- RBAC: `mdmsoft/yii2-admin` + `user_has_resource` (papéis `NV_*`); checagens `Logged::can()`, `UserHasResource::permite()`.
- Sessão: `yii\web\DbSession` (tabela `session`, grava `user_id`); hooks `Logged::afterLogin/afterLogout`.
- API: JWT (`sizeg/yii2-jwt`) com claim `uid`; exige `user_ceon.can_use_api=1`.

### Módulos
| Módulo | Entidades principais |
|---|---|
| performance | Cliente→UnidadeNegocio→Aplicacao→Projeto→Contexto→Coleta→ColetaEstrutura(PVF/PVE)→ColetaIndicador→ColetaAlternativa; Vertical; PlanoAcao; ParecerTecnicoColeta |
| risk | RiscoCenario (pontuacao = Risco Global), Risco (probabilidade×consequência), RiscoModelo* |
| financial | cf_fc_*, cf_dre_*, cf_bp_*, cf_indice_*, cf_endividamento_* |
| operational | co_ocorrencia*, co_criticidade_*, co_checklist_* |
| contract | cm_contract, cm_contract_item, cm_additive, cm_provider |
| task | cm_task, cm_task_assignment |
| api | API REST JWT expondo entidades de performance |

### Score ("Por que a nota é 79?")
- `ColetaAlternativa.nivel_referencia` (0–100) + `selecionado=1` → pontuação do indicador (`round(nivel)`).
- Subir a árvore: `pontuacao = round( Σ (taxa_substituicao * pontuacao_filho) / 100 )`.
- Vértice (`tipo_estrutura='V'`, sem `cod_pai`) = **valor global** (`Coleta::getValorGlobal()`).
- Cor: `>=80 verde, >=20 amarelo, senão vermelho` (`Contexto::getLgpdPontuacao`); faixas por cliente/projeto customizadas (`cliente_tem_faixa`, `projeto_tem_faixa`).
- Acima: Aplicacao = média dos projetos; Cliente = média das aplicações.
- Risco: `RiscoCenario.pontuacao = round(consequencia_valor * probabilidade/100)`; limites 20/50/80 → Baixo/Moderado/Alto/Crítico.

### Frontend
- 90% server-rendered; números injetados no HTML/JS; AJAX p/ modais e `pesquisa-*` (ContextoController).
- Layouts: `main.php` e `contexto.php`; notificações via drawer; `app.js` (data-action AJAX), Highcharts/amCharts4/gauge.js.

---

## Diagnóstico entregue (FASE 1)
- Arquivo: `C:\projetos\apps\privia-inteligente\docs\agente\diagnostico-privia.md`
- Decisão do usuário: **backend LLM Híbrido** (Ollama local padrão; trocável por endpoint OpenAI-compatível via env).

---

## Próximos comandos planejados (a executar)

### ETAPA 5 — Acesso seguro ao banco
```
docker exec mysql57 mysql -uroot -prootpassword -e "
  CREATE USER IF NOT EXISTS 'agent_readonly'@'%' IDENTIFIED BY '<senha>';
  GRANT SELECT ON mlabs.* TO 'agent_readonly'@'%';
  FLUSH PRIVILEGES;"
```
(validar: `SHOW GRANTS FOR 'agent_readonly'@'%';`)

### ETAPA 2-3 — Knowledge store + indexação de código
```
docker exec mysql57 mysql -uroot -prootpassword mlabs < migrations/agent_schema.sql
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/knowledge/index
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/knowledge/search "nota"
```

### ETAPA 6 — Agente com tool-calling
```
docker exec -w /var/www/html/privia-inteligente php74_apache php -l modules/agent/components/LLMClient.php
docker exec -w /var/www/html/privia-inteligente php74_apache php -l modules/agent/components/Agent.php
docker exec -w /var/www/html/privia-inteligente php74_apache php -l ... (demais arquivos)
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/test/diagnostico --coleta=<id>
```

### ETAPA 4+7 — Telas + integração do widget de chat
```
docker exec -w /var/www/html/privia-inteligente php74_apache php -l modules/agent/widgets/AgentWidget.php
# validação no navegador: abrir <app>/performance/contexto/lgpdv2?id=... e testar o chat
```

### ETAPA 8-9 — Testes com perguntas reais
```
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/test/pergunta "Por que minha nota é 79?" --contexto=<id>
docker exec -w /var/www/html/privia-inteligente php74_apache vendor/bin/codecept run unit
```

### Sintaxe (lint) de todos os arquivos novos (via container PHP)
```
docker exec php74_apache sh -c "find /var/www/html/privia-inteligente/modules/agent -name '*.php' -exec php -l {} \\;"
```
**Status: EXECUTADO** — todos os arquivos sem erro de sintaxe.

---

## Implementação do módulo `agent` (09/09) — resumo dos comandos e respostas

### Esquema (aplicado)
```
docker exec mysql57 mysql -uroot -prootpassword mlabs < modules/agent/sql/agent_schema.sql
```
Tabelas criadas: `agent_knowledge` (RAG-lite/BM25: tipo, entidade, rota, arquivo, classe, metodo, tabela, coluna, regra, descricao, fonte, confianca), `agent_conversa` (memória por usuário). Reaplicável (`IF NOT EXISTS`).

### Indexação do conhecimento
```
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/index
> Arquivos varridos: 1202 | registros novos: 2465
> Total em agent_knowledge: 2465

docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/schema
> Registros novos: 2   (tabelas/colunas do information_schema já cobertas pelo indexador)
```
Correção aplicada: `config/db_console.php` apontava para `localhost` (socket vazio no container) → passou a ler `DB_HOST/DB_NAME/DB_USER/DB_PASSWD` (default `mysql57`/`mlabs`/`root`/`rootpassword`) e passa a funcionar via `php yii ...`.

### Busca no conhecimento
```
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/search "Coleta"
```
Resposta: rotas (performance/contexto/recupera-coleta-pvf, risk/risco-modelo/lista-coletas-possiveis…), classes/tabelas (Coleta→coleta, ColetaEstrutura→coleta_estrutura, ColetaAlternativa→coleta_alternativa), getters e flag de algoritmo.

### Cadeia da nota (teste de mesa real, coleta 3048)
```
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/cadeia --coleta=3048
```
`coleta 3048 (01/06/2026) → contexto 454 (GICON Contabilidade, cliente 339) → valor_global = 67` — vértice "Segurança", filhos diretos: Governança 30×97, Áreas e Processos 50×68, Segurança de Informações 20×20 → soma 67,1 → **67**. Fórmula e soma reconstruída bateram com o armazenado.

### LLM — troca Ollama → DeepSeek
Problemas encontrados com o Ollama local (`qwen2.5:3b`):
1. `parameters: {"properties": {}}` vazio → HTTP 400 `"Value looks like object, but can't find closing '}' symbol"`. Corrigido em `AgentTool::getDefinition()` (nunca emite `properties` vazio).
2. Latência alta (120–160s por execução) e alucinação de detalhes (ex.: peso/percentual inventados).
3. Nomes de ferramenta com ponto (`coleta.cadeia_nota`) rejeitados pelo DeepSeek (`^[a-zA-Z0-9_-]+$`) → renomeadas para `coleta_cadeia_nota`, `context_get`, `db_select`, `db_schema`, `coleta_faixas`, `knowledge_search`, `code_search`, `code_read`.

Configuração atual (`.env`, gitignored — chave não vai ao repositório):
```
AGENT_LLM_BASE_URL = https://api.deepseek.com/v1
AGENT_LLM_API_KEY  = sk-*** (REDACT)
AGENT_LLM_MODEL    = deepseek-v4-pro
AGENT_LLM_TIMEOUT  = 300
AGENT_MAX_ITERATIONS = 10
```

### Teste de ponta a ponta (DeepSeek v4-pro)
```
docker exec -w /var/www/html/privia-inteligente php74_apache php yii agent/pergunta "Por que a minha nota global é 67? E por que segurança da informação ficou so 20?" --coleta=3048
> [9 iterações, 40s] ferramentas: context_get, coleta_cadeia_nota, db_schema, knowledge_search, code_read, db_select
```
Resposta final (correta, conferida contra o banco):
- Nota global 67 = arredondar((30×97 + 50×68 + 20×20)/100) = 67,1 → 67.
- Segurança de Informações = 20 porque: Controles Organizacionais (peso 20, resposta "Leve"=100) → 20; Controles de Pessoas (15), Físicos (15) e Tecnológicos (50) **sem resposta → 0**. Total = 20. Os vetores de dados conferidos em `coleta_indicador`/`coleta_alternativa` da coleta 3048 (id 50561-50564).

### Integração nos layouts
- `config/web.php` / `config/console.php`: módulo `agent` registrado (web) e `controllerMap.agent` → `AgentController` (console).
- `views/layouts/main.php` e `views/layouts/contexto.php`: `AgentWidget::widget()` antes do `endBody()`.
- Widget floating bubble: módulo `agent` → bolha + painel de chat (chat/context no backend, CSRF, hover de status).

### Pendências técnicas
- `TenantGuard` está conectado à identificação real (contexto.DbContexto); testar isolamento com 2 usuários de clientes distintos.
- Widget precisa de teste visual na tela real (`performance/contexto/lgpdv2?id=454`).
- O `ChatController` está registrado (allowActions) — validar no app com chave de sessão válida.
- **TAREFA INTERNA (usuário, NÃO fazer agora):** padronizar as faixas da tela de contexto via `Base::faixasPadrao()` — hoje o `ContextoController` usa `minimoComprometedor=20`/`minimoExcelencia=80` fixos; o agente já considera a regra `FaixasTool`/`Base::faixasPadrao()` (projeto_tem_faixa → cliente_tem_faixa → padrão).

---

## Isolamento de tenant validado pelo fluxo HTTP (09/09)

### Correções aplicadas
1. **Bug A — `AgentDb`**: FORBIDDEN incluía `INFORMATION_SCHEMA.COLUMNS`, usado internamente por `TenantGuard::fks()`.
   - `AgentDb::validarSelect($sql, $internal=false)`; `queryAll/queryOne` ganharam `$internal` — intro­specção (information_schema) passa liberada, as tools `db_*` do LLM continuam com a lista completa de bloqueio.
2. **Bug B — `AgentWidget`**: `AgentAsset::register()` falhava (`Class ... not found`) porque a classe está em `app\modules\agent\assets` e o widget a resolvia no namespace `widgets`. Adicionado `use app\modules\agent\assets\AgentAsset;`.
3. **Bug C — `TenantGuard`** (3 falhas no caminho até o Cliente):
   - `caminhoAteCliente`: usava `isset($comCliente[$x])` → verdadeiro até com valor `false` (tabela referenciada por FK mas **sem** `cliente_id`); retornava `fim` = a própria tabela prematuramente. Troca por `!empty($comCliente[$x] ?? false)`.
   - `resolveClienteId`: `SELECT {$caminho['fim']}.cliente_id …` onde `fim` é a **tabela base** de um JOIN — MySQL sombreia o nome base quando a tabela é aliased (`Unknown column 'contexto.cliente_id'`). Seleção passou a usar o **último alias** (`__jN` ou `__t0`).
   - geração de aliases usava `count($caminho['joins'])` constante → aliases duplicados em caminhos com >1 join. Corrigido com contador `++$i`.

### Teste (SCRIPT)
```
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\teste-isolamento.ps1
```
Secretos de sessão: `curl.exe` + cookie jars em `%TEMP%\opencode\agx_*.txt`; assertivas por usuário.

```
[PASS] ceon      :: is_ceon=True coleta_3048=True contexto_454=True coleta_cross_3029=True
[PASS] clia_positivo_3048               :: cliente_ids=[339] coleta=3048 contexto=454
[PASS] clia_cross_3029_bloqueado        :: coleta_3029_aceita=False contexto_683_aceito=False
[PASS] clib_positivo_3029               :: cliente_ids=[328] coleta=3029 contexto=683
[PASS] clib_cross_3048_bloqueado        :: coleta_3048_aceita=False contexto_454_aceito=False
[PASS] e2e_chat_clia_3048 :: ok=True iteracoes=2 ferramentas=context_get,coleta_cadeia_nota resposta_contem_67=True
RESULTADO: 6/6 testes passaram
```

### Detalhes do fluxo HTTP (pegadinhas)
- **CSRF**: o token do `<meta name="csrf-token">` de `GET /site/login` **fica inválido após o login** (Yii regenera sessão/token). Buscar token **fresco** com `GET /` seguindo redirect (`curl -L`) já autenticado.
- **`/` autenticado** → 302 para `/performance/formulario-resposta/index` (dashboard) — precisa `-L`.
- **UTF-8 4 bytes (emoji)**: respostas do LLM contendo emoji falhavam ao salvar em `agent_conversa` (coluna utf8 mb3 + conexão `charset=utf8` → SQLSTATE 1366 "Incorrect string value '\xF0\x9F...'"). O widget mostrava "Falha na requisição." (JSON de exceção, sem campo `error`). Correção em camadas:
  1. `ALTER TABLE agent_conversa / agent_knowledge / agent_feedback CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` (no servidor);
  2. `config/db_agent.php` — conexão Yii dedicada `dbAgent` com `charset=utf8mb4` (mesmo servidor/banco do app; lê `DB_*` ou `APP_DATABASE_*`), registrada em web.php e console.php;
  3. `AgentConversa::getDb()` e `AgentFeedback::getDb()` → `Yii::$app->get('dbAgent')`;
  4. `AgentDb::getPdo()` DSN `charset=utf8` → `utf8mb4` (leituras do agente).
- Schema para novas instalações atualizado: `modules/agent/sql/agent_schema.sql` com `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`.

---

## Soft-delete e histórico do widget (09/09)

### Problema
- O agente (Ceon) listou **clientes inativos** — o Privia usa **exclusão lógica** (soft-delete), não apaga registros.
- A cada abertura o widget começava sempre do zero (mensagem de boas-vindas), sem mostrar a conversa anterior.

### Pesquisa das regras por tabela
- Código: `app/models/*` usam `ATIVO=1`/`INATIVO=0` (BaseModel/Base); módulo `contract` usa o escopo `->active()`, e `cm_contract.status` é **workflow** (STATUS_ONGOING/FINISHED), não soft-delete.
- Banco: `information_schema` → a maioria das tabelas tem **`status`**; **`active`** em `contexto`, `cm_contract`, `cm_provider`, `cm_additive`, `co_local`, `trigger`, `trigger_input`, `co_ativo`.

### Implementação
1. **`SoftDeleteRegistry`** (`modules/agent/components/`): mapa tabela→coluna derivado de `information_schema` (cache por requisição, `$internal=true`) + `OVERRIDES` para `cm_contract/cm_provider/cm_additive`→`active` (status é workflow). `filtroPara()`: `status != 0`, `active = 1`, `excluido = 0`, `deleted_at IS NULL`.
2. **`db_select`**: parâmetro novo `incluir_inativos` (default `false`) — sem ele, o filtro é **injetado automaticamente** se a tabela tem coluna de soft-delete e a chamada não a filtrou. Resposta JSON ganhou campo `softdelete` (coluna + filtro aplicado ou motivo de ignorado).
3. **`db_schema`**: anota a coluna de soft-delete na listagem de colunas e devolve `soft_delete` (coluna + filtro padrão).
4. **systemPrompt**: regra nº10 — soft-delete; por padrão só ATIVOS; inativos/excluídos somente se pedido explícito; nunca contar inativos como ativos.
5. **`agent_knowledge`**: `seed_softdelete.php` (idempotente) grava 15 regras `tipo='regra'` (geral + cliente/contexto/coleta/projeto/aplicacao/unidade_negocio/vertical + exceções `active`).
   - Obs.: coluna `fonte` é `varchar(20)` — usar somente valores do enum (`REGRA`, `DADO REAL`, …).

### Histórico no widget
- `ChatController::actionHistorico()` — GET JSON, sem LLM, últimas 30 mensagens em **ordem cronológica** (reverso de `ultimas()`).
- `widget.php` ganhou `data-historico-url`; JS: na primeira abertura busca o histórico, remove as boas-vindas se houver mensagens e renderiza na ordem.

### Validação
```
[histórico] ok=true total=6 1:user→2:assistant→…→6:assistant (ordem cronológica)
[chat-ceon] "Confirmei agora na base (tabela cliente, filtrando apenas ativos — status != 0). São 60 clientes ativos no total"
regressão isolamento: 6/6 PASS (inclusive e2e coleta 3048 → '67')
```

---

## Gancho de fine-tuning (09/09)

- **Pool de exemplos**: `agent_feedback.resposta_ideal` = "padrão ouro". Registrados com `php yii agent/feedback` ou via `scripts/seed_feedback_qa.php` (idempotente por texto da pergunta).
- **Exemplos validados E2E** (avaliacao=5, modelo `deepseek-v4-pro`):
  1. `Quais clientes posso visualizar?` → usar USUÁRIO ATUAL + IDS VALIDADOS; super-tenant = total + somente ativos (`status != 0`); não-super-tenant = só vínculos validados.
  2. `Quem sou eu?` → identidade a partir da sessão (nunca inventar), com implicação de acesso.
  3. `Qual a nota global da coleta 3048 e por quê?` → `coleta_cadeia_nota` + decomposição `round(Σ peso*pontuação/100)` = 67.
  4. `Liste os clientes incluindo os inativos.` → único caso onde inativos aparecem (pedido explícito; `incluir_inativos=true`).
- **Export**: `php yii agent/export-feedback [--exportArquivo=@app/data/agent-feedback.jsonl]` → JSONL (UTF-8, `JSON_UNESCAPED_UNICODE`), 5 exemplos.
- **Nota**: o `seed_feedback.php` original (exemplo id=2) permaneceu como primeiro par.

---

## Rate-limit e testes unit (09/09)

- **Rate-limit**: `ChatController::actionSend` conta mensagens `role=user` recentes de `agent_conversa` (`AgentConversa::contagensRecentes`) e, se `>= AGENT_RATE_LIMIT_MAX` (default 10) dentro de `AGENT_RATE_LIMIT_WINDOW` (default 60s), responde **HTTP 429** antes de chamar o LLM. Janela deslizante; novo login não zera o contador.
- **Harness de testes unit (`vendor/bin/codecept run unit` → OK 8/8, 30 assertions)**:
  - `config/test_db.php`: aponta para o `mysql57` + banco **isolado** `ceon_tests` (lê `DB_HOST/DB_USER/DB_PASSWD` com fallback), nunca o `mlabs` de produção.
  - `ceon_tests.user_ceon`: seedado com `admin` (id 100, `auth_key=test100key`, senha `admin`, `can_use_api=1`) e `demo` (senha `demo`) — contrato do template.
  - `UserTest::testFindUserByAccessToken` adaptado ao **JWT** real: token lcobucci/jwt 3.3.3 com claim `uid` (chave/issuer/audience de `params.jwt`).
  - **Bug latente corrigido**: `ContactForm::contact` usava `params['senderEmail']`/`['senderName']` que não existiam (quebraria o envio real do formulário) → adicionados em `params.php`.

### Validação
```
[rate-limit MAX=1] 1ª msg → http=200 ok=true iter=1 ; 2ª msg → http=429 ok=false "Muitas perguntas em pouco tempo..." ; .env restaurado ; msgs de teste apagadas
[codecept] OK (8 tests, 30 assertions) — DB=ceon_tests
```

## Lazy-load, spinner e auditoria de exclusão (09/09)

- **Fix do lazy-load**: `carregarAntigos` lia `body.firstChild` (nó de texto com whitespace do markup do widget) e fazia `ref.className.indexOf(...)` → `TypeError` silencioso; trocado por `body.children[0]` e comparação por `getAttribute('class')`. Reproduzido e corrigido com jsdom (lazy-load deixa de ser engolido e o divisor de dia na fronteira entra no lugar certo).
- **Spinner de loading**: `.ag-loading` + `.ag-spinner` (borda girante) no carregamento inicial do histórico e durante o lazy-load; removido no fim; barra de status mostra "Carregando histórico…".
- **"Quem e quando excluiu"**: trilha de auditoria na tabela `mlabs.log` (não existe coluna de exclusão nas entidades). Ensinado no systemPrompt (regra 12) e indexado via `seed_auditoria_exclusao.php`. Caminho que o agente segue: `db_select(log, where={"mensagem":"Excluiu Coleta"}, order=id DESC)` + `incluir_inativos=true` na `coleta` + resolução do nome em `user_ceon`.

### Validação
```
[jsdom] render inicial 7 msgs (0 spinners) ; lazy 7+4 msgs + 1 divisor (0 spinners) ; sem erros JS
[E2E ceon] "quem e quando foi excluída a coleta 17/03/2026 IBDCNI..." → Gledson Cardoso, lotes 17/03 10:58–10:59 e 11:23 (ids 3018–3024); 3025 ativa não excluída ; 8 iterações ; msgs de teste apagadas
[regressão] scripts/teste-isolamento.ps1 → 6/6 PASS
```

## Saudação personalizada e rodapé (09/09)

- **Saudação com nome a cada abertura**: `AgentWidget::nomeUsuario()` resolve o nome da identidade logada (`name`, fallback `username`) e injeta `data-usuario-nome` no markup; `widget.php` não tem mais a boas-vindas estática. O JS `abrir()` insere a bolha `ag-saudacao` no topo ("Olá {nome}, tudo bem? Em que posso ajudá-lo?" — sem nome vira "Olá! Tudo bem?..."), e `fecharPainel()` a remove para reaparecer a cada abertura (nunca duplicada).
- **Sem "Nenhuma conversa anterior."**: histórico vazio mantém apenas a saudação (branch removido).
- **Sem "Fontes:..." no rodapé**: `setStatus` pós-resposta limpa o status (campo `ferramentas` já não é exibido).
- **Lazy-load convive com a saudação**: histórico (inicial e lazy) fica **acima** da saudação — a saudação é inserida na base e o prepend das antigas entra sempre no topo; divisor de dia na fronteira entre os dias.

### Validação
```
[jsdom] 15 checks: saudação com nome na BASE (mensagens acima); removida no fechar e reaplicada (1 só) em reabrir; histórico vazio só com saudação; lazy 09/08→09/09 com divisor entre dias e saudação ainda na base; 0 erros JS — fonte e arquivo servido (14 OK)
[servido] md5 JS/CSS = fonte ; sem 'Nenhuma conversa'/'Fontes: ' no JS publicado
[regressão] scripts/teste-isolamento.ps1 → 6/6 PASS
```

## Feedback no widget (avaliar resposta) (09/09)

- **Backend**: `ChatController::actionFeedback` (POST JSON `{pergunta, resposta, conversa_id?, avaliacao?, observacao?}`) grava em `agent_feedback` via `dbAgent` (utf8mb4); `pergunta` obrigatória (≤2000), `avaliacao` 1–5, `observacao` opcional (≤2000); salva `user_id`, `modelo` (env `AGENT_LLM_MODEL`); `actionSend` passou a devolver `resposta_id` (id da resposta salva).
- **Widget**: cada resposta do agente (histórico, lazy-load e ao vivo) ganha a faixa `.ag-feedback` com 👍 (avaliação 5 imediata) e 👎 (avaliação 1 + textarea opcional "como deveria ser a resposta?" → `observacao`). Após envio: botões desabilitados + classe `ag-fb-feito`; status "Feedback enviado. Obrigado!". A pergunta é resolvida no DOM (última bolha `ag-msg-usuario` acima), `conversa_id` vem do `data-id` da bolha. Saudação/mensagens do usuário não recebem faixa.
- **CSS**: `.ag-feedback`, `.ag-fb-botao`, `.ag-fb-correcao`, `.ag-fb-texto`, `.ag-fb-enviar`, `.ag-fb-feito`.

### Validação
```
[jsdom] 15 checks: faixas em todas as 4 respostas do histórico; nenhuma em mensagens do usuário nem na saudação; 👍 → POST com pergunta correta + avaliacao=5 + conversa_id; estado desabilitado; 👎+correção → 2º POST avaliacao=1 + observacao; envio ao vivo usa resposta_id como data-id e ganha faixa; 0 erros JS (fonte e arquivo servido)
[E2E HTTP] POST /agent/chat/feedback (agente.ceon) → {"ok":true,"id":7}, registro com user_id 1332 + modelo do env + avaliacao 5; teste removido
[suítes] codecept 8/8 (30 assertions) ; teste-isolamento 6/6 PASS
[servido] md5 JS/CSS = fonte ; bundle republicado em c9b47990
```

## Desempenho — modelo e loop do agente (09/09)

- **Modelo**: conta DeepSeek serve só `deepseek-flash` (com *reasoning content* — o nome antigo `deepseek-v4-flash-vision-exp` passou a mapear para ele) e `deepseek-v4-pro`. `.env` agora usa `AGENT_LLM_MODEL = deepseek-v4-pro` (o "melhor" escolhido pelo usuário); o modelo é lido do `.env` em **todos** os pontos (LLMClient → chamadas; ChatController → `modelo` do feedback).
- **Diagnóstico da lentidão**: loop com até 10 iterações sequenciais + `max_tokens=1200` apertado + contexto crescendo → 2min+ em perguntas densas.
- **Ajustes aplicados**: `AGENT_MAX_ITERATIONS = 7` no `.env` (default do código segue 6; valor 7 + 1 síntese quando estoura); `LLMClient::maxTokens` 1200 → 2500; `Agent::run` envia histórico `last 10` com cap de 2500 chars/mensagem; até 4 tool_calls por iteração; resultado de ferramenta truncado em 3000 chars.
- **Medição (E2E real, agente.ceon, HTTP cronometrado)**:
  - "Quais clientes posso acessar?" (aberta) → 66s/6 iter → **28.8s/3 iter** com teto 5.
  - "Nota global da coleta 3048 + decomposição" → 24s/2 iter → **17.6s/2 iter**.
  - Registros de teste (agent_conversa 109–116) removidos.
- **Nota de teste**: widget envia `application/x-www-form-urlencoded` (não JSON); login + POST de chat exige token CSRF **fresco** obtido de página autenticada após login.
- **Thinking mode do v4-pro**: com tool calling, a API exige devolver o `reasoning_content` do assistente na chamada seguinte — `LLMClient` agora retorna o campo e `Agent::run` o anexa à mensagem `assistant` com `tool_calls` (corrige o HTTP 400 "reasoning_content in the thinking mode must be passed back"). Validado E2E: "lista de coletas do meu cliente" (agente.clia) → 22.8s/3 iter, resposta com coletas ativas.
- **Performance final (09/09, 23h)**: `thinking mode` vira configurável — `AGENT_LLM_THINKING` no `.env` (`disabled` por padrão; `enabled` volta ao raciocínio profundo). `LLMClient` só envia o `thinking` como `{"type":...}` quando suportado (host deepseek = default disabled; outros provedores não recebem o campo). Prompt ganhou o bloco "ESQUEMA ESSENCIAL" (coleta/contexto/cliente/user_ceon/log + flags de soft-delete) e a regra 11 manda pular `db_schema` e ir direto ao `db_select` com where/order/limit. Resultado E2E da pergunta que estourava o teto (agente.clia, "coletas do meu cliente"): **32s/5 iter/estourou → 9.7s/2 iter/sucesso** (1 db_select, 4 coletas ativas).
- **Rastreabilidade à prova (09/09, 23h)**: com thinking off o modelo tentou responder valores (nota 67) sem ferramenta. Duas defesas: (a) regras 1 e 4 endurecidas — números só depois de chamar a ferramenta; (b) **enforcement no loop** — se a resposta final tem dígitos e NENHUMA ferramenta foi usada na rodada, `Agent::run` manda 1 correção ("use a ferramenta agora") e refaz; se reincidir, entrega (1 tentativa). E2E: decomposição da nota → **0 ferramentas/8.7s → 2 iter/`coleta_cadeia_nota`/10.7s**; lista de coletas → 2 iter/`db_select`/8.8s. Linhas de teste removidas.
- **Tela cheia do assistente (09/09, 23h–24h)**: widget ganha botão **⛶ expandir** (`agent-widget.js` → `.ag-acoes .ag-expandir`, URL gerada por `AgentWidget::run()` → `/agent/chat?coleta_id&contexto_id&projeto_id&rota`). A página `/agent/chat` (`views/chat/index.php`) é full-screen (`agente-widget--full`), com botão "← Voltar" (`#ag-fechar`) e mesmas capacidades do widget (histórico, lazy-load, saudação, feedback, Enter). **Refactor**: lógica compartilhada extraída para `agent-chat-core.js` (`window.AgentCore.init(root, opts)`; `modoPagina` = painel auto-aberto + `voltar`), `agent-widget.js` e `agent-page.js` viraram bootstrap (`AgentCore.init` com `pagina:false/true`). `AgentAsset` agora expõe core+widget; novo `AgentPageAsset` (css widget+page; js core+page). `ChatController::beforeAction`: visitante em `index` → redirect `/site/login`; demais actions seguem JSON. **Preso no harness**: jsdom não navega — teste `reproduce5.js` passou `referrer` + stub `history.back` para validar o voltar sem navegar.
- **Sem beco sem saída no teto de iterações (09/09, 24h)**: pergunta real "quais indicadores são os piores?" (CIGA) devolveu 2x o texto frio "Não consegui concluir a investigação dentro do limite de passos". Fechado com 3 mudanças: (a) `AGENT_MAX_ITERATIONS = 7`; (b) até 4 tool_calls/iteração (antes 3) para paralelizar buscas; (c) **síntese final**: ao estourar o teto, `Agent::run` faz UMA chamada extra SEM ferramentas instruindo o modelo a resumir só os dados já coletados (resposta fica `parcial:true`; responses de chat ganham o campo `parcial` e o frontend exibe "Resposta parcial — atingi o limite de passos"); síntese com conteúdo contendo `<invoke`/`tool_calls` (modelo tentando chamar ferramenta de novo) é descartada → cai na mensagem fria. E2E de regressão (agente.clia, contexto 454, "quais indicadores são os piores?"): a resposta muda de `estourou` para resposta correta de **escopo/autorização** (coleta 3048 fora do ids_validados do usuário, conforme regras 1/4/11) em **12.7s/4 iter**; e 1º casamento da síntese (sem a instrução) comprovou o fallback: `parcial=true, 8 iter, 24.9s` com conteúdo vazado do `<invoke>` — corrigido com a instrução de resumo + saneamento. Probes removidos (6 linhas, user 1333).

- **Geração de documentos pelo agente (09/10)**: o assistente agora gera **Excel (.xlsx), PDF, Word (.docx) e imagem/gráfico (PNG)** a partir de dados reais consultados na mesma rodada. Implementação: `ArquivosService` (`modules/agent/components/`; xlsx via PhpSpreadsheet com cabeçalho estilizado + autofiltro + **anti formula-injection** `= + - @`; pdf via mPDF (A4 ou paisagem, tabela renderizada); docx = **OOXML mínimo via ZipArchive, sem dependência de PhpWord**; png via GD com fallback bitmap quando GD **sem FreeType** no container); arquivos salvos em `runtime/agent_arquivos/{user_id}/{token}.{ext}` (privados, TTL 24h) e entregues por `/agent/chat/arquivo?t&n` (**autenticado**; visitante → redirect login; token validado `/^[a-f0-9]{8,32}$/`); tool nova `gerar_arquivo` (formato/nome/titulo/colunas/dados/grafico) com anexos retornados `Agent::run → 'anexos'` e expostos no JSON de `/send`; regra 13 no systemPrompt mantém o conteúdo **só** com dados de `db_select` e cap de 300 linhas; **enforcement**: se o usuário pediu arquivo e o modelo responde só com texto sem `gerar_arquivo`, `Agent::run` força 1 correção; frontend renderiza anexo como chip (`ag-anexos/.ag-anexo`, ícone por formato, tamanho, `target=_blank`). E2E real (agente.ceon): 1ª tentativa respondeu com texto (sem anexo) → reforço da regra 13 + enforcement; 2ª gerou **usuarios_sistema.xlsx (43 linhas, ID/Nome/E-mail) em 21.9s/7 iter/parcial=false**, download autenticado com **md5 idêntico** ao do runtime e visitante recebendo **302** para login. Suítes jsdom: reproduce3/4 (widget) e reproduce5 (tela cheia, +2 casos de anexo) 100% verdes; assets republicados com md5 servido==fonte.

- **Refino da geração de documentos conforme feedback (09/10)**: 3 pedidos do usuário implementados e validados. **(1) Dados completos**: `ConsultaSegura::selecionar()` centraliza o SELECT seguro (projeção validada, sem colunas sensíveis, tenant-escopado, soft-delete por padrão) e `gerar_arquivo` ganhou o parâmetro `consulta` (busca TODOS os registros, limite cheio de 500) em vez de ficar à mercê da transcrição do modelo; para **função/cargo** (inexistente em `user_ceon`) foi adicionado **join seguro** — apenas 1 tabela de whitelist (`auth_assignment`/`auth_item`), condição `tabela.coluna = tabela.coluna` (AND), colunas restritas também nela; `DbSelectTool` refatorado p/ usar o mesmo serviço (cap 200). **(2) Sem URL no texto**: regra 13 reescrita — gerar SEMPRE dados completos (exceto amostra pedida), NUNCA escrever o link de download; reforço defendido com scrub `/agent/chat/arquivo` no JS (texto exibe só o chip). **(3) Download limitado à conversa**: `agent_conversa.anexos` guarda o JSON de anexos da mensagem assistant; o URL agora é `/agent/chat/arquivo?t=<token>&id=<resposta_id>` e exige id de MENSAGEM ASSISTANT DO PRÓPRIO USER com o token cadastrado nela. E2E real (agente.ceon): "Gere uma lista de usuários em excel como nome, e-mail, CPF e função" → `lista_de_usuarios.xlsx` com **411 registros** (Nome/E-mail/Função, função via join, 2 iterações), resposta sem URL de download, download autenticado md5==runtime; autorização: sem `id` ⇒ 400, id inexistente/token inválido ⇒ 404, visitante ⇒ 302; jsdom reproduce3/4/5 verdes; assets republicados (md5==fonte); linhas de teste do 1332 removidas.

- **Resposta bonita + download sem URL (09/10)**: após o usuário apontar que a tabela aparecia crua e que a URL **absoluta** (`http://privia-inteligente.localhost/agent/chat/arquivo?...`) continuava copiável: (1) o núcleo do chat agora renderiza as respostas do agente com **markdown-lite seguro** — só DOM (`createElement`/`textContent`, nunca `innerHTML`): **tabelas markdown** viram `<table class="ag-tabela">` (cabeçalho azul, zebra, `<strong>` para `**num**`), cabeçalhos `#…####`, listas `- / * / 1.` e parágrafos; (2) o **scrub** de `limpaTextoArquivo` passou a cobrir markdown-links e URLs **com ou sem domínio** (`(https?://…)?/agent/chat/arquivo?t=…`), e o `Agent::run` ganhou um **enforcer** que, se a resposta final citar `/agent/chat/arquivo` com anexo gerado, obriga 1 passada para remover; regra 13 e instrução pós-`gerar_arquivo` agora dizem explicitamente "nem relativa, nem com domínio completo". Validação: jsdom reproduce5 (15 OK) com resposta contendo tabela markdown + URL absoluta (removeu a URL, renderizou a tabela com 2 linhas e negrito) e E2E real ("gere uma imagem do perfil de desempenho com as 3 últimas coletas") → anexo png gerado, **SEM-URL-NO-TEXTO**, download por `id` 200; assets republicados (md5 servido==fonte).

- **Download só pelo clique no artefato + tabela à prova de IA (09/10)**: o usuário confirmou que a tabela ficou bonita **porém** a IA se perdeu no cabeçalho (colocou a primeira parte da resposta na "primeira célula") e que o link continuava **abrindo em outra guia**. Duas frentes: **(1) endurecer o download** — `actionArquivo` agora é **POST-only** (`MethodNotAllowedHttpException` em GET ⇒ copiar/colar URL em outra aba devolve **405**) com CSRF obrigatório (`POST sem CSRF ⇒ 400`); o chip `.ag-anexo` deixou de ter `target="_blank"` e virou gatilho de `baixarAnexo()` em `agent-chat-core.js`: `fetch POST` c/ `X-CSRF-Token` → `blob` → `<a download="nome.ext">` clicado via JS (sem navegação/nova aba). **(2) renderizador resiliente** — regra **14** no systemPrompt ensina o formato (tabela sempre com pipes `|` e separador `|---|---|`; introdução/observação em parágrafo próprio antes/depois; `### ` em linha própria; nunca TAB em vez de `|`), e o JS ganhou: `quebraCabecalhos()` (quebra `###` colado no fim de frase), **detecção de tabela em TAB** (linhas com `\t` e colunas consistentes viram `<table>` mesmo sem pipes/separador) e ramo p/ **cabeçalho grudado em título** (`### Título<TAB>Col A…` ⇒ h3 + tabela). Validação: jsdom reproduce5 (20 OK) reproduzindo o bug real (introdução + `### ` na mesma linha, TSV sem separador, URL absoluta no texto) — parágrafo + h3 + tabela renderizados, scrub remove a URL, chip dispara POST com CSRF e dispara download `blob`; E2E real completo 2x: excel **411 linhas** SEM-URL-NO-TEXTO, **GET direto ⇒ 405**, **POST autenticado ⇒ 200** md5==runtime, matriz autorização (400 sem id / sem CSRF, 404 id/token inválido, 302 visitante), imagem SEM-URL, assets md5==fonte; resíduos do 1332 zerados.

## Voz no assistente — TEMPO REAL + agente do Privia (09/10, validada pelo usuário)

- **Arquitetura final (híbrida)**: voz em **tempo real** via Gemini Live (`gemini-3.1-flash-live-preview`, WebSocket `BidiGenerateContent`) com **function calling**: quando a pergunta precisa de dados do sistema, o modelo chama `consultar_privia` → o JS chama `/agent/chat/send` (agente real, com tools/tenant/geração de arquivos) → o resultado volta via `toolResponse` e o modelo **fala** a resposta. Assim a voz conversa fluido E executa tarefas reais (ex.: "gere um relatório dos usuários em excel" → gera o `.xlsx` e mostra o chip de download).
- **Backend**:
  - `ChatController::actionVoiceToken` (GET autenticado) → entrega `token` (do `.env`, nunca em HTML), `model`, `systemInstruction` (com `VoiceResumo`) e `tools` (`functionDeclarations: consultar_privia`).
  - `VoiceResumo` (novo): injeta no prompt da voz — **acesso real do usuário** (super-tenant = todos; senão lista de clientes acessíveis com nomes/ids), **dados reais da tela** (contexto/view → contexto name, cliente, gauge `pontuacao`), **glossário essencial do Privia** (valor global/coleta/contexto/faixa/perfil de desempenho) e **regras de `agent_knowledge` da rota** (até 6). Defensivo (try/catch) — nunca quebra a voz.
  - Mantidos para a opção turn-based (provedor trocável): `voice/VoiceBackend` (interface), `voice/GeminiVoiceBackend` (STT `gemini-3.5-transcribe`, TTS `gemini-3.1-flash-tts-preview` com PCM L16→WAV, auth `AQ.` via `?key=` / `AIza` via header, retry com backoff 5xx) e `VoiceService` (facade por `AGENT_VOICE_BACKEND`). Actions `voice-transcrever`/`voice-falar` ainda existem.
- **Frontend** (`agent-voice.js`, transporte trocável): ao clicar no 🎤 o **microfone abre imediatamente** em paralelo com token/WS (buffer de áudio até `setupComplete`); bolhas do usuário via `inputTranscription`, resposta em áudio via `modelTurn`, texto da resposta no fim do turno via `outputTranscription`+`turnComplete`; **`toolCall` é lido no NÍVEL RAZ da mensagem** (`resp.toolCall`, não `serverContent`); na chamada, renderiza bolha do agente + anexos (`core.anexaArquivos`) e devolve `toolResponse`. Erro/fechamento inesperado avisa "Clique no microfone para reconectar". Novos ganchos no core: `enviarTexto`, `adicionaMensagem`, `anexaArquivos`, `setRespostaListener`.
- **CSS**: botão circular 38px, ícone SVG, hover/focus, estado `ag-voz-ativo` (vermelho `#c62828` + anel pulsante).
- **Env**: `AGENT_VOICE_BACKEND`, `AGENT_GEMINI_API_KEY` (token `AQ...` do usuário), `AGENT_GEMINI_MODEL_STT`, `AGENT_GEMINI_MODEL_TTS`, `AGENT_GEMINI_MODEL_LIVE`, `AGENT_GEMINI_VOICE`, `AGENT_GEMINI_TIMEOUT`, `AGENT_GEMINI_RETRIES`.
- **Validação**: lint PHP + `node --check`; `voice-token` com resumo real (contexto 454 GICON, gauge 67, clientes); protocolo Live via Node — `setupComplete`, `toolCall` com pergunta completa, `toolResponse`, modelo falando "…lista_de_usuarios.xlsx com 411 registros", `turnComplete`; assets servidos com o fix. **VALIDADA pelo usuário no navegador (encerrada).**
- **Nota**: token `AQ...` é de sessão e expira; produção → API key `AIza` (mesmo fluxo).

- **Limitar uso da IA por usuário (09/10)**: env `AGENT_ALLOWED_USER_IDS` (ids de `user_ceon` separados por vírgula; vazio = todos). Novo `AgentAcesso::permitido()` aplicado no `beforeAction` do `ChatController` (todas as actions: send/historico/feedback/voz/arquivo/index) e no `AgentWidget` (bolha não renderiza para bloqueados). Bloqueado ⇒ HTTP 403 JSON (`{ok:false,error:'Acesso restrito...'}`); index/arquivo ⇒ ForbiddenHttpException. Validado: vazio → agente.ceon 200; lista=[4] → agente.ceon 403 + widget oculto, gledson(4) 200.

## PENDÊNCIA DE ENSINO AO MODELO — perfis de acesso do Privia (09/09)

> O usuário vai explicar (em breve) a visão de cada perfil para ensinar o modelo como o Privia funciona:
> **Admin** (Ceon/super-tenant), **Responsável por Cliente**, **Responsável por Contexto**,
> **Responsável por Plano de Ação** e **usuário que acessa via link**.
> Contexto do caso real: usuário de teste tinha o cliente liberado (Organização ID 283, 3 contextos ativos:
> testes 527/529 e "(UF) Contexto Avaliado" 700) mas **nenhuma contexto/coleta validada na sessão** — o agente
> explicou corretamente os `ids_validados` vazios, mas os perfis ainda não foram formalmente ensinados ao modelo.
> [Aguardando explicação do usuário — NÃO seguir implementação de RBAC/roles antes disso.]

---

<!-- Seção de atualizações: o agente deve manter esta tabela e "Respostas" após cada novo comando executado. -->