# IA do atendimento e o motor do Fluxo Construtor

> Contexto consolidado em set/2026, a partir da atividade #479827 (documentar o CRM do admin).
> Serve como referência de entendimento do motor de automação e do agente de IA para futuras implementações.

> Mecânicas relacionadas — **não duplicar aqui**:
> - Canvas, diretiva DrawFlow e controllers de tela do editor → [ui-fluxo-construtor-editor.md](ui-fluxo-construtor-editor.md)
> - Atendimento, origens, textos padrão → [../CRM/atendimento-crm.md](../CRM/atendimento-crm.md)
> - Processo, trâmite e centro de custo → [../Unico/processos-centro-custo.md](../Unico/processos-centro-custo.md)
> - Auditoria de comportamento do motor (jul/2026, 19 achados) → [../../analises/auditoria-motor-fluxo-construtor/relatorio.md](../../analises/auditoria-motor-fluxo-construtor/relatorio.md)

---

## O que é

O **Fluxo Construtor** é um motor de fluxo visual: o usuário desenha um grafo de nodes numa tela e o backend executa esse grafo. Ele tem duas faces que são o mesmo motor:

- **automação determinística** — nodes encadeados com condição, laço, chamada HTTP, ação no ERP. O desenhista decide cada passo;
- **agente de IA** — um node de provider de IA que conversa com um LLM e recebe *tools* penduradas nele por um tipo especial de conexão. O LLM decide o que chamar.

A segunda face é a que responde o "configurar uma IA para responder o atendimento". Mas não é a única: o CRM suporta **três** integrações de IA, e o Fluxo Construtor é apenas a mais nova delas.

### Os quatro escopos

Todo fluxo tem um tipo (`FluxoConstrutor::fc_tipo`) e todo node declara em que escopo pode ser usado (`NodeInterface::USE_*`). É o que determina onde um fluxo pode rodar:

- `USE_GLOBAL = 'GLOBAL'` — nodes disponíveis em qualquer fluxo
- `USE_CRM_SAC = 'CRM_SAC'` (tipo `1`) — fluxos de atendimento
- `USE_UNICO_PROCESSO = 'UNICO_PROCESSO'` (tipo `2`) — fluxos de trâmite de processo
- `USE_API = 'USE_API'` (tipo `3`) — fluxos expostos como endpoint

Os valores do escopo são string; os do tipo do fluxo são inteiro. São listas paralelas, e `USE_API` é o único cujo nome da constante não segue o padrão dos outros três.

Pontos de partida do código:

- `src/Unico/Model/FluxoConstrutor/BO/FluxoConstrutorCompilerBO.php` — o compilador, a peça central
- `src/Unico/Controller/FluxoConstrutor/Executor.php` — o loop de execução
- `src/Unico/Model/FluxoConstrutor/Registry/NodeRegistry.php` — o catálogo de nodes e seus escopos
- `src/CRM/Controller/AtendimentoBotFilaController.php` — onde as três integrações de IA se separam
- `src/CRM/Controller/Api/Panobot/` — a camada Panobot (o caminho Dialogflow CX)

---

## Configuração de IA por origem de atendimento

### As três integrações

Cadastradas em `CRM\Model\OrigemAtendimentoConfiguracaoModel`:

- `PANOBOT_IA_TIPO_INTEGRACAO_DIALOGFLOW_ES = 1` — "Dialogflow ES"
- `PANOBOT_IA_TIPO_INTEGRACAO_DIALOGFLOW_CX = 2` — "Dialogflow CX"
- `PANOBOT_IA_TIPO_INTEGRACAO_CONSTRUTOR = 3` — "Panobot IA Construtor"

> **Armadilha:** `Unico\Model\Processo\FluxoGrupo` tem constantes com o **mesmo nome** e **valores diferentes** (`1 = Dialogflow CX`, `2 = Construtor`). Os números não são intercambiáveis entre CRM e Processos. Ver [../Unico/processos-centro-custo.md](../Unico/processos-centro-custo.md).

### Quando usar cada uma

| Integração | O que é | Quando usar |
|---|---|---|
| **Dialogflow ES** (1) | Integração mais antiga, com o Dialogflow clássico do Google. Restou o mínimo: `Model/Dialogflow/ES/` tem só `ClientSession` e `ProcessQuestion`, e o controller de API dela está marcado `@deprecated` | **Legado.** Não usar em origem nova; existe para não quebrar canais que ainda não migraram |
| **Dialogflow CX** (2) | O agente vive no Google; o ERP é chamado por webhook e responde com um `TextoPadrao` renderizado. É a camada **Panobot** | Quando o desenho da conversa (intents, páginas, fluxos) já está montado no Dialogflow e o ERP só precisa fornecer dados e textos |
| **Fluxo Construtor** (3) | O agente vive **dentro do ERP**: o fluxo é desenhado no admin, e o node de IA chama o provider (OpenAI, DeepSeek) diretamente | Caminho atual para automação nova. Dá controle total do grafo, permite misturar passos determinísticos com IA e não depende de conta no Google |

`isIntegracaoPanobotValida()` define o que cada uma exige para ser considerada configurada:

- ES → `getCredentiaslProjectId()` preenchido (o typo está no fonte)
- CX → `getEnvironmentDialogFlowCX()` preenchido
- Construtor → `getFluxoConstrutorVersaoId()` preenchido

Origem sem essa configuração simplesmente não é processada pelo bot — e o log registra "Origem atendimento sem integração válida para processamento", sem erro.

Tela: `OrigemAtendimentoConfiguracaoFormController`, com o trait `Traits/TraitFormControllerConfiguracaoDialogflowCX`.

### A fila do bot

**Entidade:** `CRM\Model\AtendimentoBotFila` → `crm.crm_atendimento_bot_fila`

Toda mensagem nova entra na fila pelo `@PostPersist` de `AtendimentoMensagem`, que dispara `AtendimentoBotFilaEventController`. Situações (de `Dialogflow\Base\InterfaceFilaBotSituacao`, compartilhadas com o trâmite de processo):

- `SITUACAO_NOVO = 1`
- `SITUACAO_RESPONDIDO = 2`
- `SITUACAO_SEM_RESPOSTA = 3`
- `SITUACAO_ERRO = 4`
- `SITUACAO_RESTRICAO = 5`
- `SITUACAO_PROCESSANDO = 6`

**Entidade:** `CRM\Model\AtendimentoBotTermo` → `crm.crm_atendimento_bot_termo` — termos que disparam (ou barram) o bot, por origem.

O despacho está em `AtendimentoBotFilaController::processFila()`: ES e CX caem no mesmo `processFilaDialogflow()`, Construtor cai em `processFilaFluxoConstrutor()`.

### Continuidade de conversa

`processFilaFluxoConstrutor()` resolve a sessão antes de executar:

1. busca o `AtendimentoFluxoConstrutorSessao` mais recente do atendimento (`crm.crm_atendimento_fluxo_construtor_sessao`);
2. **descarta a sessão se a versão do fluxo mudou na origem** — trocar o fluxo configurado reinicia a conversa de todos os atendimentos abertos, de propósito;
3. executa com `ExecutionInputDataAtendimento($atendimento, $textoPergunta)`;
4. vincula a sessão nova ao atendimento, inclusive **no caminho de erro** — sem isso a sessão de erro ficaria órfã e a próxima mensagem recomeçaria do zero.

O resultado vira situação de fila: exceção tratada pelo gerenciador global → `ERRO`; fluxo rodou mas não respondeu o cliente → `SEM_RESPOSTA`; respondeu → `RESPONDIDO`.

### Contexto passado para a IA

`CRM\Model\AtendimentoPanobotParametros` (value object sobre `ate_panobot_parametros`) guarda o que já foi trocado com o bot:

- `getParametrosLastRequest($consideraAtendimentosRelacionados = true)` — os parâmetros da última chamada. O flag faz a busca subir para atendimentos relacionados do mesmo cliente, para o bot não perder o contexto quando a conversa se fragmenta em vários atendimentos;
- `setReplyBlocked($replyBlocked, $reabrirAtendimento = true)` — silencia o bot naquele atendimento. É o que os nodes de bloqueio/desbloqueio e as operações `lock-bot-response` / `unlock-bot-response` acionam. O bloqueio é logado.

---

## Camada Panobot (o caminho Dialogflow CX)

Panobot é o nome da integração em que **o agente está fora** e o ERP responde perguntas dele.

### Entrada

- `Api/Panobot/PanoBotIaApiController` — entrada de atendimento. Único provider aceito: `PROVIDER_DIALOGFLOWCX = 'dialogflowcx'`
- `Api/Panobot/PanoBotIaProcessoController` — entrada de processo: abre ou tramita processo a partir de um texto padrão
- `Api/PanoBotIaApiController` — **o legado**, namespace `CRM\Controller\Api` (sem `\Panobot`), marcado `@deprecated Utilizada pela versão Dialogflow ES`. Os dois arquivos têm o mesmo nome de classe em namespaces diferentes; conferir o `use` antes de editar

### O ciclo de uma requisição

`PanoBotIaApiController::post()`:

1. `PanobotOperationParameterFactory::create()` resolve o handler da operação e chama `defineParameters()`;
2. o texto padrão é buscado por `TextoPadraoRepository::findByPanobotIAParams()`, filtrando pelos parâmetros que o provider identificou;
3. `ProcessTextoPadraoResponse` renderiza o template (Twig, carregando só as variáveis que o texto cita — ver [../CRM/atendimento-crm.md](../CRM/atendimento-crm.md));
4. se a resposta é válida, `PanoBotIaProcessoController::callThreadAberturaProcesso()` abre processo caso o texto esteja configurado para isso;
5. `processOperacaoRespostaBot()` aplica o `replyBlocked` do texto padrão ao atendimento;
6. um log é gravado no atendimento dizendo **qual texto padrão** respondeu e com quais parâmetros;
7. `$provider->response()` devolve no formato do provider.

O passo 6 é o que torna a resposta do bot auditável: dá para abrir o atendimento e ver exatamente qual registro do catálogo respondeu.

### O contrato do provider

`Core/AbstractPanobotBaseProvider` é abstrata e exige:

- `response(ProcessTextoPadraoResponse)` — serializa no formato do agente
- `erro(Exception)`
- `identifyParameters(): Enumerator` — extrai do payload os parâmetros que filtram o texto padrão
- `getTipoTextoPadraoUse(): int` — `TIPO_PANOBOT_IA_ATENDIMENTO` ou `TIPO_PANOBOT_IA_PROCESSO_TRAMITE`

As capacidades do provider são detectadas por **trait**: `providerUseAgent`, `providerUseIntentOrTag`, `providerUseVariableDefaultMessage`, `providerUseCanalVenda`, `providerUseIdentificador`, `providerUseVariableProdutoDerivacao`, `providerUseVerificaRestricaoResposta`. Um provider novo ganha ou perde comportamento por composição, sem `if` de tipo.

Única implementação hoje: `Provider/DialogflowCXProvider`.

### Operações: o que a IA pode pedir ao ERP

`Core/Interfaces/InterfaceOperationsPanobotIA::ALL_OPERATIONS` lista 16 operações, e cada uma tem exatamente um handler em `Core/OperationHandlers/`. O factory resolve por **convenção de nome** — `list-ordens-compra` → `ListOrdensCompra` → `ListOrdensCompraParameterProvider` — e lança exceção se a classe não existir.

- `mensagem` → `MensagemParameterProvider`
- `parametros` → `ParametrosParameterProvider`
- `consultar-frete` → `ConsultarFreteParameterProvider`
- `caracteristica-produto` → `CaracteristicaProdutoParameterProvider`
- `detail-produto` → `DetailProdutoParameterProvider`
- `list-pedidos` → `ListPedidosParameterProvider`
- `list-pedido-itens` → `ListPedidoItensParameterProvider`
- `list-pedido-transporte` → `ListPedidoTransporteParameterProvider`
- `list-processos` → `ListProcessosParameterProvider`
- `list-notas-fiscais` → `ListNotasFiscaisParameterProvider`
- `list-ordens-compra` → `ListOrdensCompraParameterProvider`
- `list-manual-produto` → `ListManualProdutoParameterProvider`
- `list-produtos-relacionados` → `ListProdutosRelacionadosParameterProvider`
- `list-recibos-entrega` → `ListRecibosEntregaParameterProvider`
- `lock-bot-response` → `LockBotResponseParameterProvider`
- `unlock-bot-response` → `UnlockBotResponseParameterProvider`

**O conceito a reter:** o agente externo não consulta o banco. Ele pede uma operação nomeada; o handler traduz aquilo em parâmetros que alimentam o template do texto padrão. Adicionar uma capacidade nova é criar uma constante e uma classe com o nome derivado dela — não há registro a atualizar.

### O que é legado e o que não é

- `Model/Dialogflow/ES/` — **legado**, 2 arquivos
- `Model/Dialogflow/CX/` — atual do caminho Panobot
- `Model/Dialogflow/IntentResponse.php` e `QuestionUtil.php` — **compartilhados**, não são legado
- `Controller/Job/DialogFlowJobController` — o job da fila (note: fica em `Controller/Job/`, não em `Model/Dialogflow/`)
- `Controller/Api/DialogFlowApiController` e `DialogFlowProcessoHelperController` — webhooks do Dialogflow
- `Unico/Controller/Api/Integracao/Google/DialogflowHttpRequest` — o cliente HTTP

---

## O motor: modelo de dados

### `FluxoConstrutor` — o fluxo

**Entidade:** → `un_fluxo_construtor`

| Propriedade | Coluna | Observação |
|---|---|---|
| `id` | `fc_id` | |
| `tipo` | `fc_tipo` | `1 = USE_CRM_SAC`, `2 = USE_UNICO_PROCESSO`, `3 = USE_API` |
| `codigo` | `fc_codigo` | Único **por tipo** (`uq_un_fluxo_construtor_tipo_codigo`) |
| `nome` / `descricao` | `fc_nome` / `fc_descricao` | |
| `debug` | `fc_debug` | Liga a gravação dos passos de execução |
| `timeToLiveSessao` | `fc_ttl_sesao` | Minutos. Default `DEFAULT_TTL_SESSAO = 30`. O typo `sesao` está na coluna |
| `configuracao` | `fc_configuracao` | `json`. Ex.: `CONFIG_KEY_API_METODOS = 'api_metodos_permitidos'` |

O TTL da sessão é o que define por quanto tempo uma conversa "lembra" do que já aconteceu.

### `FluxoConstrutorVersao` — o desenho versionado

**Entidade:** → `un_fluxo_construtor_versao` (único por `fc_id` + `fcv_codigo_versao`)

| Propriedade | Coluna | Observação |
|---|---|---|
| `codigoVersao` | `fcv_codigo_versao` | `CODIGO_DRAFT = 'draft'` é reservado |
| `configuracao` | `fcv_configuracao` | `json` — **o grafo**: `{nodes: [...], connections: [...]}` |
| `hash` | `fcv_hash` | sha256 do JSON. **Gravado apenas no draft** |
| `descricaoVersao` / `dataCriacao` / `usuario` | `fcv_descricao_versao` / `fcv_data_criacao` / `usu_id` | |

O timeout de execução é guardado dentro do JSON de configuração da versão, sob `CONFIG_KEY_EXECUTION_TIMEOUT = 'executionTimeoutInSeconds'`.

### `FluxoConstrutorParametro` — o que não cabe no desenho

**Entidade:** → `un_fluxo_construtor_parametro`

Credenciais, providers e variáveis globais ficam fora do grafo para não vazarem no JSON da versão.

Tipos:

- `TIPO_CREDENCIAIS_HTTP_REQUEST = 1`
- `TIPO_PROVIDER_IA = 2`
- `TIPO_CREDENCIAIS_PROVIDER_IA = 3`
- `TIPO_GLOBAL_VARIABLE = 4`
- `TIPO_PROVIDER_IA_MODEL = 5` — **não persiste**; existe só para listar modelos dinamicamente
- `TIPO_GLOBAL_FUNCTION = 6`
- `TIPO_PROVIDER_IA_MODEL_EMBEDDING = 7` — **não persiste**; idem, para modelos de embedding

Escopos: `ESCOPO_POR_FLUXO = 'por_fluxo'` (amarrado a um `fc_id`) e `ESCOPO_COMPARTILHADA = 'compartilhada'`.

Cada tipo tem um value object em `Model/FluxoConstrutor/Configuracao/` que lê o JSON de `fcp_configuracao`: `...CredenciaisHttpRequest`, `...CredenciaisProviderIA`, `...ProviderIA`, `...GlobalVariable`, `...FuncaoGlobal`.

O diretório `Seeder/` semeia os providers conhecidos: `OpenAIProviderSeed` e `DeepSeekProviderSeed`, via `ProviderIASeeder`.

### `FluxoConstrutorSessao` — a memória da conversa

**Entidade:** → `un_fluxo_construtor_sessao`

| Propriedade | Coluna | Observação |
|---|---|---|
| `sessionId` | `fcs_session_id` | `unique` |
| `fluxoConstrutorVersao` | `fcv_id` | |
| `variables` | `fcs_variables` | `json` — as variáveis do fluxo, sobrevivem entre mensagens |
| `metadata` | `fcs_metadata` | `json` |
| `pendingNodes` | `fcs_pending_nodes` | `json` — **onde a execução parou**. É o que permite retomar |
| `updatedAt` | `fcs_updated_at` | Base do TTL |
| `tipo` | `fcs_tipo` | `1 = PRODUCAO`, `2 = TESTE`, `3 = ERRO` |
| `hashVersao` | `fcs_fcv_hash` | O hash da versão quando a sessão nasceu |

`fcs_fcv_hash` é o detector de sessão obsoleta: se o desenho mudou desde que a conversa começou, o hash não bate mais e a sessão não deve ser retomada sobre um grafo diferente.

Duas tabelas especializam a sessão por domínio, cada uma no schema do seu módulo:

- `CRM\Model\FluxoConstrutor\AtendimentoFluxoConstrutorSessao` → `crm.crm_atendimento_fluxo_construtor_sessao`
- `Unico\Model\Processo\FluxoConstrutor\ProcessoFluxoConstrutorSessao` → `processo.pro_processo_fluxo_construtor_sessao`

E o trait `CRM\Model\FluxoConstrutor\TraitConfiguracaoFluxoConstrutorModel` (**não é entidade**) é a ponte config → fluxo: dá a qualquer model de configuração os getters `fluxoConstrutor` / `fluxoConstrutorVersao`. Usado por `OrigemAtendimentoConfiguracaoModel` (CRM) e `FluxoGrupoConfiguracaoModel` (Processos).

### `FluxoConstrutorExecucao` — o registro de uma corrida

**Entidade:** → `un_fluxo_construtor_execucao` (`fcs_id`, `doc_id`, `fcs_updated_at`)

Só três colunas: o dump de debug da execução **não fica em coluna**, vai para um `DocumentoEletronico`. É o que permite guardar passos de execução grandes sem inchar a tabela.

---

## Publicação e compilação

Este é o ponto mais mal compreendido do motor, então vale ser literal.

### O que acontece ao salvar

`FluxoConstrutorFormController` grava o desenho, em transação:

1. persiste o `FluxoConstrutor`;
2. joga a configuração no **draft** (`FluxoConstrutorVersaoRepository::getOrCreateFluxoConstrutorVersaoDraft()` — um draft por fluxo);
3. compila o draft e grava na versão o **timeout calculado**;
4. o `@PrePersist`/`@PreUpdate` da versão recalcula `fcv_hash` — **só se for draft**;
5. se o usuário pediu "salvar versão utilizável", `createNewFluxoConstrutorVersao()` **copia o JSON do draft** para uma linha nova com o código informado.

Publicar é copiar. Não existe transformação entre draft e versão publicada: são o mesmo JSON em duas linhas.

### `FluxoConstrutorVersaoCompiled` não é uma versão compilada armazenada

**A classe não tem `@Table` e não é entidade.** É um objeto em memória — id, nome, `AbstractNode[]`, `AbstractConnection[]` — produzido por `FluxoConstrutorCompilerBO::compile()` e guardado num **cache estático por request** (`self::$compileds[$versaoId]`).

`Executor::getCompiled()` chama exatamente o mesmo compilador. Ou seja: **a execução lê o JSON cru e recompila a cada corrida.** Nada de compilado é persistido em lugar nenhum.

O que "compilar" significa aqui, em ordem:

1. `validateConfiguracao()` — exige as chaves `nodes` e `connections`
2. `compileNodes()` — injeta `tipoFluxoConstrutor` em cada node e instancia via `NodeRegistry::create()` → `$class::beanModel()`
3. `compileConnections()` — instancia via `ConnectionRegistry`
4. `validateFluxo()` — as cinco asserções abaixo
5. `orderConnections()` — ordena as conexões por `from`

As cinco asserções:

- `assertStartNodeExists()` — precisa existir um `StartNode`
- `assertMoreThanStartNode()` — precisa ter algo além dele
- `assertSingleConnectionRules()` — node marcado `isSingleConnection()` não aceita mais de uma conexão de entrada
- `assertNodeLimits()` — respeita o `limit` da definição (hoje aplicado ao `SleepNode`)
- `assertToolConnectionCompatibility()` — conexão do tipo *tool* só pode sair de node que declara a capacidade. Conexões incompatíveis ficariam inertes em runtime, então são barradas no save

Como a compilação roda no save, **um desenho inválido não chega a ser publicado**. O preço é recompilar a cada execução.

### O timeout é derivado do desenho

`FluxoConstrutorVersaoCompiled::calcularExecutionTimeout()` soma:

- `EXECUTION_TIMEOUT_POR_NODE_SECONDS = 10` por node;
- mais o timeout configurado de cada `HttpRequestNode`;
- mais os segundos de cada `SleepNode`;
- mais `HttpRequestNode::MAX_TIMEOUT` para cada `ProviderAIRequestNode`.

O resultado é limitado entre `MIN_EXECUTION_TIMEOUT_SECONDS = 600` e `MAX_EXECUTION_TIMEOUT_SECONDS = 900`. Fluxo grande não estoura o teto — e fluxo pequeno ganha o piso.

---

## Catálogo de nodes

27 classes em `Node/` e uma em `Node/Tool/`, todas registradas em `NodeRegistry::registerAll()`. O escopo vem do registro, não da classe.

### Controle de fluxo — `USE_GLOBAL`

- `StartNode` — a entrada. Obrigatório
- `ExceptionStartNode` — a entrada do caminho de erro global
- `IfNode` — desvio binário
- `SwitchNode` — desvio por valor
- `MergeNode` — junta ramos paralelos e só segue quando todos chegarem
- `SleepNode` — espera. Tem limite de uso por fluxo
- `ExecutaFluxoNode` — chama outro fluxo como subfluxo
- `DefineVariavelNode` — grava variável na sessão

### Integração e dados — `USE_GLOBAL`

- `HttpRequestNode` — chamada HTTP, com credenciais vindas de `FluxoConstrutorParametro`
- `MovimentacaoFinanceiraNode` — lança movimentação financeira

### API — `USE_API`

- `ApiResponseNode` — define a resposta do endpoint
- `ValidateApiDataNode` — valida o payload de entrada

### Atendimento — `USE_CRM_SAC`

- `AbrirAtendimentoNode`
- `RespondeAtendimentoNode`
- `BloqueiaAtendimentoNode` e `DesbloqueiaAtendimentoNode` — o `replyBlocked`. **Compartilham um único output** (`AtendimentoReplyBlockNodeOutput`)
- `DefinePedidoAtendimentoNode` e `DefineProdutoAtendimentoNode` — vinculam pedido e SKU ao atendimento
- `AcaoAtendimentoMarketplaceNode` e `AcaoPosVendaMarketplaceNode`
- `OcorrenciaRastreioAtendimentoNode`
- `CotacaoFreteNode`
- `AguardaEventoNode` e `DisparaEventoNode` — **também `USE_CRM_SAC`**, apesar de parecerem primitivas genéricas
- `FluxoProcessoAtendimentoNode` — abre processo a partir do atendimento

### Processo — `USE_UNICO_PROCESSO`

- `FluxoProcessoProcessoTramiteNode`

### IA

- `ProviderAIRequestNode` (`USE_GLOBAL`) — a chamada ao LLM. Parâmetros: `messages` (com `role`/`content`), `provider`, `credenciais`, `model`, `image_urls`, `temperature`, `top_p`, `max_tokens`, `presence_penalty`, `frequency_penalty`, `response_format`
- `RAGSearchToolNode` (`USE_GLOBAL`, registrado com `ToolConnection::TYPE_TOOL_CONNECTION`) — a única *tool*

`Node/Output/` tem 25 classes para 27 nodes: além do output compartilhado de bloqueio/desbloqueio, `RAGSearchToolNode` não tem output próprio — ele não é executado pelo loop, é chamado pelo LLM.

### Como uma tool se pendura no agente

`AIToolNodeInterface` (em `Node/Tool/`) define quatro métodos:

- `getAIToolName(): string` — o nome da *function* exposta ao LLM
- `getAIToolDescription(): string`
- `getAIToolParameters(): array` — o JSON schema dos argumentos
- `executeAITool(array $arguments): string` — executa e devolve texto

A ligação é feita por **conexão**, não por configuração: o desenhista liga o node de tool ao `ProviderAIRequestNode` com uma `ToolConnection`. No runtime, `Executor::buildExtraConnectionSet()` percorre as conexões do tipo *tool* que chegam ao node atual e monta o `NodeExtraConnectionSet` que o node de IA usa para declarar suas funções ao provider.

**O conceito:** as capacidades do agente são desenhadas no canvas, ao lado do resto do fluxo — não configuradas num lugar separado.

---

## Como criar um node novo

| Camada | O que fazer |
|---|---|
| `Node/Base/AbstractNode` | Estender e implementar os três abstratos: `execute(): AbstractNodeOutput`, `static getDefinition(): NodeDefinition`, `static beanModel(array): AbstractNode` |
| `Node/Base/NodeDefinition` | Descreve o node para o canvas: tipo, label, parâmetros, `isSingleConnection()`, `getLimit()`, conexões extras |
| `Node/Output/` | Uma subclasse de `AbstractNodeOutput` com o que o node entrega ao contexto. Reaproveitar um output existente é legítimo |
| `ParameterDefinition/` | Escolher o tipo de campo do formulário do node entre os 12 disponíveis: `Text`, `Textarea`, `Number`, `Boolean`, `Select`, `MultiSelect`, `SelectDynamicLoad`, `Suggest`, `SuggestMultiple`, `GridForm`, `ExpressCondition`, `CallAction`. Os auxiliares são `FieldTypes/SuggestFilter` e `FieldValues/SelectDynamicLoadValueDTO` — um arquivo cada |
| `Connection/` | Só se o node precisar de uma conexão especial. Existem três: `DefaultConnection`, `ExceptionConnection`, `ToolConnection` |
| `Node/Exceptions/` | Se a falha do node deve ser fatal, estender `AbstractNodeFatalException` — ela **não** é capturada pelo tratamento de erro configurado |
| `Registry/NodeRegistry::registerAll()` | Registrar a classe com o escopo correto. Sem isso o node não existe para o compilador nem para o canvas |

O registro valida `is_subclass_of(AbstractNode::class)` e indexa por `getDefinition()->getType()` — o tipo é a chave que aparece no JSON do grafo.

Os demais registries seguem o mesmo padrão: `ConnectionRegistry`, `ParameterDefinitionRegistry`, `ExecutionInputDataRegistry`, `ProviderIARegistry`.

---

## RAG: a base de conhecimento da IA

### Modelo

**Entidade:** `FluxoConstrutorRAGArquivo` → `un_fluxo_construtor_rag_arquivo`

| Propriedade | Coluna | Observação |
|---|---|---|
| `nome` / `descricao` | `fcraq_nome` / `fcraq_descricao` | A descrição vira parte da descrição da tool para o LLM |
| `documentoEletronico` | `doc_id` | O arquivo em si |
| `status` | `fcraq_status` | `1` aguardando, `2` processando, `3` pronto, `4` erro |
| `totalChunks` | `fcraq_total_chunks` | |
| `modeloEmbedding` / `dimEmbedding` | `fcraq_modelo_embedding` / `fcraq_dim_embedding` | |
| `chunkSize` / `overlap` | `fcraq_chunk_size` / `fcraq_overlap` | |
| `provider` / `credencial` | `fcraq_provider_id` / `fcraq_credencial_id` | FKs para `FluxoConstrutorParametro` |
| `erroMsg` | `fcraq_erro_msg` | |

**Entidade:** `FluxoConstrutorRAGArquivoChunk` → `un_fluxo_construtor_rag_arquivo_chunk`

`fcraqc_numero`, `fcraqc_conteudo` e `fcraqc_embedding` — este último `@Column(type="vector", length=1536)`, isto é, **pgvector**. `ON DELETE CASCADE` a partir do arquivo.

### O ciclo

1. **Upload** — o arquivo vai para `DocumentoEletronico` pelo `FluxoConstrutorRAGArquivoFormController`.
2. **Disparo** — `FluxoConstrutorRAGProcessadorController::iniciarProcessamento()` gera uma **presigned URL** válida por `+1 hour` e chama um **serviço externo**: `https://api.panoramasistemas.com.br/panoverse-builder-flow-tools/processar`. Defaults: `CHUNK_SIZE_DEFAULT = 800`, `OVERLAP_DEFAULT = 100`.
3. **Retorno** — o serviço chama de volta o webhook `/fluxo-construtor/rag/save-chunk` (`Api/FluxoConstrutorRAGChunkWebhookApiController`), com eventos `chunk`, `done` e `error`, autenticado por header `X-Webhook-Token` com TTL de 7200s (o classificador distingue token ok, inválido e expirado). Cada chunk é embeddado no momento da gravação.
4. **Busca** — `RAG/FluxoConstrutorRAGSearchController::buscar()` embeda a pergunta e chama `FluxoConstrutorRAGArquivoChunkRepository::findChunksSimilares()`:

```sql
SELECT fcraqc_conteudo
  FROM un_fluxo_construtor_rag_arquivo_chunk
 WHERE fcraq_id IN (:arquivosIds)
   AND fcraqc_embedding IS NOT NULL
 ORDER BY fcraqc_embedding <=> CAST(:queryEmbedding AS vector)
 LIMIT :topK
```

Os trechos são concatenados com `CONTEXT_SEPARATOR = "\n\n---\n\n"` e devolvidos ao LLM como texto. `DEFAULT_TOP_K = 5`.

> Os controllers de RAG estão em **três diretórios diferentes**: o processador em `Controller/FluxoConstrutor/`, o webhook em `Controller/FluxoConstrutor/Api/` e a busca em `Controller/FluxoConstrutor/RAG/`.

Providers de IA em `Controller/Api/FluxoConstrutor/Provider/`: `BaseHttpRequestProviderIA`, `HttpRequestOpenIA`, `HttpRequestDeepSeek` e a interface `EmbeddingCapableProviderInterface` — **só o OpenAI implementa embedding**, então um arquivo de RAG não pode ser processado com credencial DeepSeek.

---

## Execução e observabilidade

### O loop

`Executor::execute()` mantém uma fila de `PendingNode` e consome com `array_shift`:

1. seta contexto e último output no node;
2. se o node aceita conexões extras, monta o `NodeExtraConnectionSet` (as tools);
3. `execute()`, guarda o output no `ExecutionContext`;
4. `logDebugStep()`;
5. `definePendingNodes()` empilha os próximos a partir das conexões;
6. se o metadata marcou **await**, guarda os pendentes na sessão e **sai do loop** — é a pausa que o `AguardaEventoNode` e o `SleepNode` produzem.

Estado de runtime em `Model/FluxoConstrutor/Executor/`: `ExecutionContext` (variáveis, último node, último output, última exceção), `ExecutionMetadata` (await, pendentes), `RunFlowState`, `MergeState` (quem já chegou no merge), `PendingNode`.

### Tratamento de erro

Três modos, em `NodeInterface`:

- `TYPE_EXCEPTION_PATH = 'PATH'` — segue pelas conexões de exceção do node
- `TYPE_EXCEPTION_IGNORE = 'IGNORE'` — registra e continua pelo caminho normal, com output nulo
- `TYPE_EXCEPTION_STOP = 'STOP'` — interrompe

`ExceptionStartNode` é a entrada do tratamento global: quando nenhuma conexão de exceção local atende, a execução salta para ele. Exceções que estendem `AbstractNodeFatalException` (como `ExecutaFluxoNodeRecursaoInfinitaFatalException`) passam por `checkFatalExceptions()` e **não** respeitam o `errorHandling` configurado.

No `run()`, falha fatal marca a sessão como `TIPO_ERRO` e grava a execução antes de relançar — mas só se não for subsessão.

### Modo teste

`Executor::test()` executa dentro de uma transação que é **sempre revertida**, e só depois abre uma segunda transação para gravar sessão e execução. Falha e sucesso seguem o mesmo caminho de persistência, para que a tela mostre os passos nos dois casos.

> O rollback protege o banco, **não o mundo externo**: chamada HTTP, resposta enviada ao cliente e requisição ao provider de IA já aconteceram quando o rollback roda.

### Onde olhar uma execução

- `Grid/FluxoConstrutor/FluxoConstrutorSessaoGridController` — as sessões, com tipo e versão
- `Form/FluxoConstrutor/FluxoConstrutorSessaoFormController`
- `FluxoConstrutorFormController` — a aba de execução do editor, que recarrega o canvas por sessão (`loadDataByFluxoConstrutorSessaoId`) e roda testes (`executeTest`)
- O dump completo dos passos fica no `DocumentoEletronico` apontado por `FluxoConstrutorExecucao`

Os passos só são gravados com `fc_debug` ligado no fluxo.

### Dados de entrada

`Executor/InputData/`: `ExecutionInputData` (base) e as três variantes que dão ao fluxo o contexto do domínio — `ExecutionInputDataAtendimento`, `ExecutionInputDataProcesso`, `ExecutionInputDataApi`. `ExecutionInputDataRegistry` faz a resolução.

`Executor/Service/` tem dois serviços de apoio consultados pelos nodes: `InformacoesNFeService` e `InformacoesRastreioService`.

---

## Comportamentos conhecidos

Em jul/2026 o motor passou por uma auditoria completa (24 nodes e o Executor, com dumps reais de produção): **19 achados**, 1 crítico e 8 altos. Antes de mexer em sessão, retomada, merge ou tratamento de erro, ler [../../analises/auditoria-motor-fluxo-construtor/relatorio.md](../../analises/auditoria-motor-fluxo-construtor/relatorio.md).

Os temas, em uma linha cada:

- **concorrência de sessão** sem lock nem versão — duas mensagens simultâneas do mesmo cliente podem perder atualização;
- **perda silenciosa de ramos** na fila de execução, em torno do stash do `MergeNode`;
- **tratamento de erro que não cumpre o contrato** — `Throwable` que não é `Exception` escapa do `errorHandling`, e falha de IA pode ser lida como sucesso;
- **retomada frágil** — versão editada in-place, entidade excluída ou output nulo envenenam a sessão, que passa a falhar a cada mensagem até o TTL expirar;
- **efeitos externos sem idempotência** re-executados na retomada.

Não duplicar o conteúdo da auditoria aqui: ela é o documento vivo desses pontos.

---

## Telas e repositórios

| Onde | O que tem |
|---|---|
| `src/Unico/Controller/Grid/FluxoConstrutor/` | 11 grids: fluxo, versão, sessão, os 5 de parâmetro, RAG arquivo e RAG chunk |
| `src/Unico/Controller/Form/FluxoConstrutor/` | 9 forms, com `FluxoConstrutorFormController` como editor |
| `src/Unico/View/Grid/FluxoConstrutor/` e `View/Form/FluxoConstrutor/` | 12 grids e 10 forms, incluindo `FluxoConstrutorEditNodeForm` e `FluxoConstrutorExecuteTestForm` |
| `src/Unico/Repository/FluxoConstrutor/` | 7 repositórios |
| `src/CRM/Repository/AtendimentoFluxoConstrutorSessaoRepository.php` | Vínculo sessão ↔ atendimento |
| `src/Unico/Repository/Processo/FluxoConstrutor/ProcessoFluxoConstrutorSessaoRepository.php` | Vínculo sessão ↔ processo |

`FluxoConstrutorVersaoRepository` ainda oferece `findLatestPublishedVersion()` e o par `findNodesReferenciamVersaoAnterior()` / `updateVersaoNode()` — SQL sobre `jsonb` que localiza nodes `executa_fluxo` apontando para uma versão antiga e os atualiza em massa. É o que permite publicar uma versão nova de um subfluxo sem caçar à mão quem o chama.

A API (`FluxoConstrutorApiController`) **recusa executar a versão draft** — só versão publicada roda como endpoint.

Para o canvas, a diretiva e os controllers AngularJS do editor, ver [ui-fluxo-construtor-editor.md](ui-fluxo-construtor-editor.md).
