Logosulfite.app
rafagazani/sulfite 999999

Sistema de Prompts

Arquitetura dos system prompts do Chat IA — designer e consumer

Sistema de Prompts#

O Sulfite Chat opera em dois modos com system prompts especializados. Em ambos, o prompt é montado server-side — o client nunca vê nem controla o conteúdo.

┌─────────────────────────────────────────────────────┐
│                    System Prompt                     │
├─────────────────────────────────────────────────────┤
│  Base (regras fixas)                                │
│  + Data Context (dados do relatório) ← server-side  │
│  + Knowledge Base (llms.txt) ← opcional             │
│  + Extra Instructions ← opcional                    │
└─────────────────────────────────────────────────────┘

Dois modos#

ModoEndpointClasseFunção
Designer POST /api/v1/chat/generate SulfiteSystemPrompts.designer() Gera JSON de ReportDefinition via linguagem natural
Consumer POST /api/v1/consumer/chat/generate SulfiteSystemPrompts.consumer() Responde perguntas sobre dados de um relatório específico

Modo Designer#

O agente Designer recebe uma descrição em linguagem natural e gera um JSON completo de ReportDefinition. O prompt inclui:

Estrutura do prompt#

┌─ Base ────────────────────────────────────────────┐
│  Papel: "Sulfite report generator"                │
│  Output: JSON em fences ```json ... ```           │
│                                                   │
│  REPORT STRUCTURE                                 │
│  - Formatos de página (A4 portrait/landscape)     │
│  - Tipos de elementos                             │
│  - Convenções de cor e coordenadas                │
│                                                   │
│  WORKFLOW (4 passos obrigatórios)                 │
│  1. Gerar direto se simples                       │
│  2. Chamar sulfite_get_schema se precisa          │
│  3. Produzir JSON após qualquer tool result       │
│  4. Sempre usar fences ```json                    │
│                                                   │
│  ELEMENT QUICK REFERENCE                          │
│  - text, field, table, barcode, chart             │
│                                                   │
│  DATA SOURCES / LABEL RULES / RULES              │
├─ Knowledge Base (opcional) ───────────────────────┤
│  Conteúdo do llms.txt, se configurado via         │
│  CHAT_LLMS_TXT_PATH                              │
└───────────────────────────────────────────────────┘

Tools disponíveis#

ToolDescrição
sulfite_get_schemaSchema campo-a-campo de um tipo de elemento
sulfite_validate_reportValida um JSON de ReportDefinition
sulfite_introspect_datasourceIntrospecta campos de um datasource
sulfite_list_reportsLista relatórios salvos

Configuração#

O Knowledge Base é opcional e controlado pela env var CHAT_LLMS_TXT_PATH:

CHAT_LLMS_TXT_PATH=assets/llms.txt

O conteúdo do arquivo é appended ao prompt base como seção ## KNOWLEDGE BASE.


Modo Consumer#

O agente Consumer é um analista de dados que responde perguntas sobre um relatório já renderizado. O prompt é montado automaticamente com os dados reais.

Estrutura do prompt (V2)#

O formato V2 usa JSON Lines com schema e summaries per-band, otimizado para aritmética LLM:

┌─ Base ────────────────────────────────────────────┐
│  Papel: "Data analyst assistant"                  │
│  Idioma: responde no idioma da pergunta           │
│                                                   │
│  DATA FORMAT                                      │
│  - Dados em JSON Lines (1 objeto por linha)       │
│  - Datas compactas: "2026-04-02" (meia-noite)     │
│  - Números reais: 3454.5 (não "3454.5")           │
│  - Cada band tem seu próprio schema               │
│                                                   │
│  RULES                                            │
│  - Consultar Summary ANTES de iterar linhas       │
│  - Usar valores do Summary diretamente            │
│  - Cada band tem campos independentes             │
│  - Se REPORT AGGREGATES existir, são autoritativos│
│  - Responder no idioma da pergunta                │
│  - Não sugerir mudanças no relatório              │
├─ Data Context (injetado pelo servidor) ───────────┤
│                                                   │
│  ## REPORT INFO                                   │
│  - Name: Dashboard Financeiro                     │
│  - ID: fin_dashboard                              │
│                                                   │
│  ## APPLIED FILTERS                               │
│  - **Data Início** (`dataInicio`, date): 2026-01  │
│  - **Região** (`regiao`, select): Sul             │
│                                                   │
│  ## REPORT SCHEMA                                 │
│                                                   │
│  ### vendas (180 rows)                            │
│  Fields: numero (text), data (date),              │
│    status (text, values: faturado|aberto|canc),   │
│    valor_total (number), margem_bruta (number)    │
│  Summary: valor_total SUM=423891 AVG=2355         │
│    MIN=87.30 MAX=17125.28 |                       │
│    status: faturado=142, aberto=31, cancelado=7 | │
│    valor_total by status: faturado=389210,        │
│    aberto=28456.22, cancelado=6225.28             │
│                                                   │
│  ### pagamentos (205 rows)                        │
│  Fields: numero (text), fornecedor (text,         │
│    values: Acme|Globex|...), data (date),         │
│    valor (number), dias_atraso (number)           │
│  Summary: valor SUM=198442 AVG=968.01             │
│    MIN=12.50 MAX=8930 |                           │
│    valor by fornecedor: Acme=48210, Globex=35100  │
│                                                   │
│  ## REPORT AGGREGATES                             │
│  - total_vendas: 423891.50                        │
│  - total_pagamentos: 198442.10                    │
│                                                   │
│  ## REPORT DATA (100 of 385 rows)                 │
│  --- vendas ---                                   │
│  {"numero":"VD-048","data":"2026-04-02",...}       │
│  {"numero":"VD-097","data":"2026-04-02",...}       │
│  --- pagamentos ---                               │
│  {"numero":"PAG-084","fornecedor":"Acme",...}      │
│                                                   │
├─ Extra Instructions (opcional) ───────────────────┤
│  Instruções de deploy customizadas                │
└───────────────────────────────────────────────────┘

Formato dos dados (V2)#

O formato V2 aplica várias otimizações para melhorar a precisão da LLM:

OtimizaçãoAntesDepoisPor quê
Schema per-band Schema flat, todos os campos misturados Cada band tem schema independente com tipos LLM não confunde campos de bands diferentes
Summaries pré-computados LLM precisava somar manualmente SUM/AVG/MIN/MAX por campo numérico + enum breakdowns + group aggregates Elimina erros de aritmética em datasets grandes
Group aggregates Sem pré-computação por grupo total by vendedor: Ana=300, Bruno=150 LLM responde "quem vendeu mais?" sem somar
Datas compactas "2026-04-02T00:00:00.000Z" "2026-04-02" (meia-noite) Menos tokens, mais legível
Coerção numérica "3454.50" (string) 3454.5 (number) LLM trata como número real, não texto
Formato BR "3.782,27" ficava texto 3782.27 (number) Valores em formato brasileiro são coercidos
Detecção de enums Sem contexto status (text, values: faturado|aberto|cancelado) LLM sabe quais valores categóricos existem
Report Aggregates Engine aggregates descartados Seção REPORT AGGREGATES com valores autoritativos AggregateElements do engine são fonte de verdade
Leading zeros "060"60.0 (número) "060" preservado como texto Códigos fiscais (CST, NCM) mantêm zeros à esquerda
Dedup de bands DANFE: canhoto + header com dados iguais × 2 Mergeados em canhoto+header (1 cópia) Sem dados redundantes no prompt
Summary inteligente Single-row band: SUM=AVG=MIN=MAX=valor Sem summary (dados já visíveis na row) Zero ruído para bands de contexto
Filtragem de constantes cfop SUM=27025 (todas as rows iguais) Omitido do summary Aggregates só para campos que variam
Identity enum filter produto: A=1, B=1, C=1 (todos com count 1) Omitido do summary (mantém values: no schema) Contagens unitárias não ajudam análise
Campos de texto com **2–20 valores distintos** são automaticamente detectados como enumerações. O schema mostra os valores possíveis e o summary mostra a contagem de cada valor — mas apenas quando há agrupamento real (≥ 2 rows com o mesmo valor). Campos onde cada row tem valor único (identity) mantêm a anotação `values:` no schema, mas não geram breakdowns nem group aggregates no summary.
Para cada combinação de **campo categórico × campo numérico**, o servidor pré-computa a soma por grupo. Exemplo:
Summary: total by vendedor: Ana=300, Bruno=150 | total by status: faturado=389210, aberto=28456

Isso permite que a LLM responda perguntas como "quem vendeu mais?" ou "qual o status com maior valor?" sem precisar somar linhas manualmente.

Filtros aplicados automaticamente:

  • Campos onde distinctCount ≥ totalRows (identity) são excluídos — produziriam uma repetição verbosa dos dados brutos
  • Campos numéricos constantes (MIN == MAX) não geram aggregates
Bands com dados idênticos (mesmos campos e valores após limpeza) são automaticamente mergeadas. Exemplo: DANFE NF-e onde `canhoto` e `header` contêm os mesmos campos → aparecem como `canhoto+header` com uma única cópia dos dados. Isso reduz tokens e evita confusão da LLM com dados duplicados.

Como o Data Context é construído#

O servidor executa tudo internamente antes de chamar a LLM:

┌─────────────┐     ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│ Client envia│────▶│  Carrega     │────▶│  Processa    │────▶│  Monta       │
│ reportId +  │     │  Definition  │     │  dados       │     │  Data Context│
│ params +    │     │  do repo     │     │  (engine)    │     │  + prompt    │
│ messages    │     └──────────────┘     └──────────────┘     └──────────────┘
└─────────────┘                                                       │
                                                                      ▼
                                                               ┌──────────────┐
                                                               │  Envia ao    │
                                                               │  LLM com     │
                                                               │  tools       │
                                                               └──────────────┘
  1. Carrega a ReportDefinition pelo reportId
  2. Processa os dados com os params (filtros) via SulfiteEngine.processReport()
  3. Exporta os dados como JSON Lines via ConsumerChatToolExecutor.exportJsonLines()
  4. Computa schema per-band, summaries (SUM/AVG/MIN/MAX), enum breakdowns, group aggregates e report aggregates
  5. Aplica filtros inteligentes: remove constantes (MIN==MAX), identity enums, single-row band summaries e bands duplicadas
  6. Monta o Data Context com report info + filtros + schema + summaries + dados
  7. Injeta no system prompt e envia à LLM
O client **nunca** tem acesso ao system prompt, aos dados brutos ou à API key da LLM. Ele envia apenas `reportId`, `params` e `messages` — o servidor faz todo o resto.

Tools disponíveis#

ToolDescrição
sulfite_aggregate_dataSUM, AVG, COUNT, MIN ou MAX sobre um campo, com filtro opcional
sulfite_group_byAgrupa por campo e agrega valor — retorna top N
Para perguntas agregadas simples ("qual o total de vendas?"), a LLM usa **diretamente os values do Summary** sem precisar chamar tools. **Group aggregates** respondem perguntas como "quem vendeu mais?" automaticamente. As tools servem para cálculos **com filtro** (ex: "total de vendas do Norte") ou groupings que não estão pré-computados.

Filtros e parâmetros#

Os parâmetros do relatório (ReportParameter) são declarados no JSON do relatório:

{
  "parameters": [
    {
      "id": "dataInicio",
      "label": "Data Início",
      "type": "date",
      "required": true,
      "defaultValue": "2026-01-01"
    },
    {
      "id": "regiao",
      "label": "Região",
      "type": "select",
      "options": ["Norte", "Sul", "Leste", "Oeste"]
    }
  ]
}

O cliente envia os valores escolhidos no campo params do request:

{
  "reportId": "uuid",
  "params": {
    "dataInicio": "2026-01-01",
    "regiao": "Sul"
  },
  "messages": [{ "role": "user", "content": "qual o total?" }]
}

O servidor usa esses params para:

  1. Processar os dados — os params são passados ao engine e substituídos nos templates SQL/URL dos datasources
  2. Documentar no prompt — os filtros aplicados aparecem na seção APPLIED FILTERS com label, id, tipo e valor

A LLM sabe exatamente quais filtros o usuário aplicou e pode mencioná-los nas respostas.

Tratamento de erros#

Quando a LLM chama uma tool com campo inexistente, o servidor retorna os campos válidos:

{
  "verb": "SUM",
  "field": "orçamento",
  "result": null,
  "note": "Field \"orçamento\" does not exist. Available fields: vendedor, numero, produto, valor, ativo."
}

Isso permite que a LLM se auto-corrija na próxima iteração.


Provedores LLM (server-side)#

A factory LlmClient.fromConfig() seleciona o client baseado na env var CHAT_PROVIDER:

ProviderCHAT_PROVIDERAutenticaçãoURL base
OpenAIopenaiAuthorization: Bearerapi.openai.com/v1
Anthropicanthropicx-api-keyapi.anthropic.com
Azure OpenAIazureapi-key headerCHAT_BASE_URL (obrigatório)
Cloudflare Workers AIcloudflareAuthorization: BearerCHAT_BASE_URL (obrigatório)
Ollamaollamanenhumalocalhost:11434/v1
GitHub Modelsgh_modelsAuthorization: Bearermodels.github.ai/inference

Variáveis de ambiente#

# Obrigatórias
CHAT_PROVIDER=azure                    # Provider (ver tabela acima)
CHAT_MODEL=gpt-4.1-mini               # Modelo ou deployment name
CHAT_API_KEY=sua-chave-aqui            # API key do provider

# Opcionais
CHAT_BASE_URL=https://...             # URL base (obrigatório para azure/cloudflare)
CHAT_MAX_ITERATIONS=8                  # Máximo de iterações do loop agentic
CHAT_LLMS_TXT_PATH=assets/llms.txt    # Knowledge base para modo designer

Azure OpenAI#

Para o Azure, o CHAT_MODEL deve ser o nome do deployment (não o nome do modelo):

CHAT_PROVIDER=azure
CHAT_MODEL=gpt-4.1-mini
CHAT_API_KEY=1UqAns...
CHAT_BASE_URL=https://meu-recurso.cognitiveservices.azure.com

O client monta automaticamente:

{CHAT_BASE_URL}/openai/deployments/{CHAT_MODEL}/chat/completions?api-version=2024-10-21

Para usar uma api-version específica, inclua na URL:

CHAT_BASE_URL=https://meu-recurso.cognitiveservices.azure.com?api-version=2025-01-01-preview

Loop agentic#

Ambos os modos usam o mesmo ChatLoop que itera entre chamadas à LLM e execução de tools:

┌────────────────────────────────────────────────────────────────┐
│                         ChatLoop                               │
│                                                                │
│  for iteration in 1..maxIterations:                            │
│    1. Envia history + system prompt + tools ao LLM             │
│    2. Se resposta contém tool_calls:                           │
│       - Executa cada tool                                      │
│       - Adiciona resultados ao history                         │
│       - Volta ao passo 1                                       │
│    3. Se resposta é texto:                                     │
│       - Stream via SSE ao client                               │
│       - Emite evento 'done'                                    │
│                                                                │
│  Guard: após N rounds sem texto, remove tools e força geração  │
└────────────────────────────────────────────────────────────────┘

Proteções#

  • Anti-loop: se a LLM ficar chamando tools sem produzir texto por 2+ rounds, o loop remove as tools e injeta instrução de "gerar agora"
  • Deduplicação: tool calls repetidas são detectadas e reportadas
  • Validação: tool names são validados contra a whitelist do executor
  • Timeout: máximo de CHAT_MAX_ITERATIONS iterações (default: 8)

Eventos SSE#

O stream retorna eventos no formato Server-Sent Events:

TipoPayloadQuando
open{}Início da conexão
text{ content: "..." }Cada chunk de texto da LLM
tool_call{ name, status, detail? }Tool sendo executada
error{ message }Erro no loop
done{}Final da resposta