Arquitetura de Conferência Estruturada — Proposta

Visão geral

O agente LLM deixa de produzir texto livre e passa a produzir um JSON estruturado que consolida as 3 fontes confiáveis. As regras de validação são aplicadas em duas etapas: o LLM preenche os dados, código determinístico valida.

┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  OCR (MCP0) │  │ Pedido(MCP0)│  │  Req (MCP1) │  │Chat(Botmkr) │
└──────┬──────┘  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘
       │                │                │                │
       ▼                ▼                ▼                ▼
┌──────────────────────────────────────────────────────────────────┐
│                     LLM (Etapa 1)                                │
│  Preenche JSON canônico com dados de cada fonte                  │
│  Faz casamento heurístico OCR ↔ Pedido ↔ Req                   │
│  Extrai contexto do chat como metadata                           │
│  NÃO aplica regras de validação — só popula                     │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │  JSON canônico   │
                  │  (dados brutos)  │
                  └────────┬────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│                   Validador (Etapa 2)                             │
│  Código determinístico (n8n Code node ou script)                 │
│  Aplica TODAS as regras de coerência                             │
│  Compara campos entre fontes                                     │
│  Resolve sinônimos via lookup table                              │
│  Marca status: ok / warn / error / info                          │
│  Calcula score de conformidade                                   │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
                  ┌─────────────────┐
                  │  JSON validado   │
                  │  (com status)    │
                  └────────┬────────┘
                           │
               ┌───────────┼───────────┐
               ▼           ▼           ▼
         ┌──────────┐ ┌────────┐ ┌──────────┐
         │ Relatório│ │ Alerta │ │  Métricas │
         │  humano  │ │ n8n    │ │ dashboard │
         └──────────┘ └────────┘ └──────────┘

Por que separar LLM e validação

O LLM é bom em: extrair dados de texto não-estruturado (OCR), fazer casamento heurístico (nome comercial ↔ nome técnico), interpretar contexto do chat.

O LLM é ruim em: aplicar regras consistentemente. Às vezes marca ml vs g como divergência, às vezes não. Às vezes esquece de comparar ff com base. Às vezes inventa que “Base Neutracolor” é gel.

Regras determinísticas (código) são: 100% consistentes, testáveis, auditáveis, versionáveis, rápidas. Sinônimos viram lookup table, coerência ff↔base vira mapa, ml≈g vira conversão.

O LLM popula. O código valida. Cada um faz o que faz melhor.


Schema do JSON canônico

{
  "order_id": "uuid",
  "conference_date": "2026-03-31",
  "req_number": "350377",
 
  "entities": {
    "prescriber": {
      "ocr": {
        "name": "Moema Matos Carvalho Ribeiro",
        "council": "CRM",
        "council_number": "014348",
        "council_uf": "BA",
        "cpf": "709.435.595-53"
      },
      "order": {
        "name": null,
        "role": "user",
        "council": null,
        "council_number": null,
        "council_uf": null
      }
    },
    "patient": {
      "ocr": {
        "name": "Moema Matos Carvalho Ribeiro",
        "cpf": "709.435.595-53",
        "address": "Avenida Osvaldo Cruz, 74..."
      },
      "order": {
        "name": null
      }
    },
    "buyer": {
      "ocr": {
        "name": "Moema Matos Carvalho Ribeiro",
        "address": "Avenida Osvaldo Cruz, 74...",
        "city": "Ilhéus",
        "uf": "BA",
        "phone": "+55 (73) 98109-8517"
      },
      "order": {
        "name": null
      }
    },
    "recipient": {
      "order": {
        "address": null,
        "shipping_type": null
      }
    },
    "same_person": null
  },
 
  "order_summary": {
    "order_date": null,
    "prescription_date": "12/03/2026",
    "general_instructions": "USO CONSULTÓRIO",
    "observations": {
      "order": null,
      "chat": null
    },
    "total_price": {
      "order": null,
      "sum_items": null,
      "sum_charges": null
    },
    "item_count": {
      "ocr": 3,
      "order": null,
      "req": null
    }
  },
 
  "item_matching": [
    {
      "match_id": 1,
      "ocr_formula_index": 0,
      "order_item_id": "832cd3b0-...",
      "req_serie": "0",
      "match_confidence": "high",
      "match_criteria": ["name_similar", "volume_compatible"]
    }
  ],
 
  "products": [
    {
      "match_id": 1,
      "req_serie": "0",
 
      "identification": {
        "name": {
          "ocr": "Creme antioxidante Noturno (15 ml)",
          "order": "Creme antioxidante Noturno",
          "req": null
        },
        "quantity": {
          "ocr": 1,
          "order": 1,
          "req": 1
        }
      },
 
      "presentation": {
        "ff": {
          "req_code": 2,
          "req_label": "creme"
        },
        "base": {
          "ocr_qsp_name": "Base Second Skin",
          "req_last_item": "Second Skin Base 15g"
        },
        "volume_per_unit": {
          "ocr_qsp_value": 15,
          "ocr_qsp_unit": "g",
          "req_volume": 15,
          "req_volume_unit": "g"
        },
        "content_total": {
          "req_contain": 15,
          "req_contain_unit": "g"
        },
        "packaging": {
          "ocr": "Bisnaga",
          "order": null,
          "req": null
        },
        "usage_application": {
          "ocr": null,
          "req": "TOPIC"
        },
        "usage_destination": {
          "ocr": "Uso Paciente",
          "order": "patient"
        }
      },
 
      "price": {
        "order": null,
        "req_charge": 50
      },
 
      "posology": {
        "ocr": "Após a descamação, usar diariamente o CREME CISTEAMINA NOTURNO por 3 horas, remover com SABONETE PHYSAVIE. Em seguida, passar CREME ANTIOXIDANTE NOTURNO todas as noites",
        "req": "USE CONFORME ORIENTAÇÃO",
        "req_truncated": true
      },
 
      "persons": {
        "patient": {
          "req": "Moema Matos Carvalho Ribeiro"
        },
        "prescriber": {
          "req_name": "Moema Matos Carvalho Ribeiro",
          "req_council": "CRM",
          "req_number": "14348",
          "req_uf": "BA"
        }
      },
 
      "dates": {
        "manufacturing": "12/03/2026",
        "expiration": "06/05/2026"
      },
 
      "composition": {
        "ocr_summary": "Inaclear 1%; Dermaspheres C 4%; Vcip 2%; ...",
        "req_summary": "Inaclear 1%; Dermaspheres C 4%; Vcip 2%; ...",
        "ingredients": [
          {
            "name_ocr": "Inaclear",
            "name_req": "Inaclear",
            "code_ocr": 6714,
            "code_req": 6714,
            "dose_ocr": 1,
            "unit_ocr": "%",
            "dose_req": 1,
            "unit_req": "%",
            "source": "both"
          },
          {
            "name_ocr": "Vcip",
            "name_req": "Vcip",
            "code_ocr": 1775,
            "code_req": 1775,
            "dose_ocr": 2,
            "unit_ocr": "%",
            "dose_req": 2,
            "unit_req": "%",
            "source": "both"
          },
          {
            "name_ocr": null,
            "name_req": "Sabonete Syndet Liquido",
            "code_ocr": null,
            "code_req": 8015,
            "dose_ocr": null,
            "unit_ocr": null,
            "dose_req": 50,
            "unit_req": "ML",
            "source": "req_only"
          }
        ]
      },
 
      "chat_context": {
        "mentions": [],
        "preferences": null,
        "adjustments": null
      }
    }
  ],
 
  "chat_context_global": {
    "raw_messages": [],
    "extracted": {
      "delivery_preference": null,
      "address_mentioned": null,
      "special_instructions": null,
      "urgency": null
    }
  }
}

Schema do JSON validado (pós-processamento)

O validador adiciona um objeto validation a cada nível:

{
  "validation": {
    "overall_status": "warn",
    "score": 0.85,
    "issues": [
      {
        "severity": "warn",
        "field": "products[0].composition.ingredients[5]",
        "type": "ingredient_ocr_only",
        "message": "Ativo 'Kawai Kirei' presente no OCR mas ausente na Req",
        "ocr_value": "Kawai Kirei 0.3%",
        "req_value": null
      },
      {
        "severity": "info",
        "field": "products[0].composition.ingredients[6]",
        "type": "ingredient_req_only",
        "message": "Ativo 'Sabonete Syndet Liquido' presente apenas na Req (excipiente/base esperado)",
        "ocr_value": null,
        "req_value": "Sabonete Syndet Liquido 50ML"
      }
    ]
  }
}

Cada produto também ganha:

{
  "product_validation": {
    "status": "warn",
    "issues_count": {"error": 0, "warn": 1, "info": 2},
    "checks": {
      "name_match": "ok",
      "quantity_match": "ok",
      "price_match": "ok",
      "ff_base_coherence": "ok",
      "ff_usage_coherence": "ok",
      "volume_match": "ok",
      "posology_match": "ok",
      "patient_match": "ok",
      "prescriber_match": "ok",
      "ingredients_match": "warn",
      "packaging_match": "no_data"
    }
  }
}

Regras de validação (código, não LLM)

Estas regras saem do prompt e viram código determinístico.

Regras de equivalência (lookup tables)

// Sinônimos de ativos
const SYNONYMS = {
  "vitamina c": ["ácido ascórbico", "vcip", "ascorbyl tetraisopalmitate"],
  "retinal": ["retinaldeído"],
  "vitamina e": ["tocoferol", "acetato de tocoferol"],
  "bha": ["ácido salicílico"],
  "niacinamida": ["vitamina b3", "nicotinamida"],
  "txa": ["ácido tranexâmico"]
};
 
// Bases compatíveis com ff
const FF_BASE_MAP = {
  1: { type: "capsule", bases: ["capsula", "cápsula"] },
  2: { type: "cream", bases: ["creme", "lanette", "polawax", "neutracolor", "second skin"] },
  3: { type: "fluid", bases: ["loção", "sabonete", "gel", "shampoo", "fluida"] },
  5: { type: "undefined", bases: [] }
};
 
// Uso-aplicação compatível com ff
const FF_USAGE_MAP = {
  1: ["ORAL", "SUBLINGUAL"],
  2: ["TOPIC"],
  3: ["TOPIC"],
  5: ["TOPIC", "ORAL", "SUBLINGUAL", "INTERNAL"]
};

Regras de comparação

function compareVolume(ocrValue, ocrUnit, reqValue, reqUnit) {
  // ml ≈ g — normalizar
  const normalize = (v, u) => {
    if (["ml", "g"].includes(u.toLowerCase())) return { value: v, unit: "ml_g" };
    return { value: v, unit: u.toLowerCase() };
  };
  const a = normalize(ocrValue, ocrUnit);
  const b = normalize(reqValue, reqUnit);
  if (a.unit !== b.unit) return "warn";
  return a.value === b.value ? "ok" : "warn";
}
 
function compareIngredient(ocr, req) {
  // qs = compatível com qualquer valor
  if (ocr.unit === "qs") return "ok";
  // código bate = mesmo ativo
  if (ocr.code && req.code && ocr.code === req.code) {
    // mesmo ativo, comparar concentração
    if (ocr.dose === req.dose) return "ok";
    return "warn_concentration";
  }
  // nome similar ou sinônimo
  if (isSynonym(ocr.name, req.name)) {
    if (ocr.dose === req.dose) return "ok_synonym";
    return "warn_concentration";
  }
  return "no_match";
}
 
function checkFFCoherence(ff, baseName, usage) {
  const ffConfig = FF_BASE_MAP[ff];
  if (!ffConfig) return "warn";
  const baseMatch = ffConfig.bases.some(b =>
    baseName.toLowerCase().includes(b)
  );
  const usageMatch = FF_USAGE_MAP[ff]?.includes(usage);
  if (!baseMatch) return "error_ff_base";
  if (!usageMatch) return "error_ff_usage";
  return "ok";
}
 
function comparePosology(ocrText, reqText) {
  if (!reqText) return "no_data";
  // Req pode estar truncada — verificar se o início bate
  // Normalizar: lowercase, remover pontuação extra
  const ocrNorm = ocrText.toLowerCase().trim();
  const reqNorm = reqText.toLowerCase().trim();
  if (reqNorm === "use conforme orientação" ||
      reqNorm === "use conforme orientacao") {
    return "ok_generic";  // posologia genérica na req, não é divergência
  }
  if (ocrNorm.startsWith(reqNorm) || reqNorm.startsWith(ocrNorm)) {
    return "ok";  // truncamento
  }
  // Verificar contradição real
  return "warn";
}

Pipeline no n8n

Webhook (orderId ou clientName)
  │
  ├── [paralelo] MCP0: getOrderDetails(orderId)
  ├── [paralelo] MCP0: getOrderOCRs(orderId)
  ├── [paralelo] MCP0: getOrderReqItemsByOrderIds(orderId)
  ├── [paralelo] Botmaker: query Postgres
  │
  ▼
Code Node: extrair reqNumber + reqSerie de cada item
  │
  ▼
Loop: para cada item → MCP1: getReqDetails(reqNumber, reqSerie)
  │
  ▼
LLM Node: receber dados brutos das 4 fontes
  │  Prompt: "Preencha este JSON canônico com os dados abaixo.
  │           Faça o casamento heurístico OCR ↔ Pedido.
  │           Extraia contexto do chat.
  │           NÃO valide — só popule."
  │  Output: JSON canônico (dados brutos)
  │
  ▼
Code Node: validador determinístico
  │  Aplica TODAS as regras
  │  Adiciona validation a cada campo
  │  Calcula score
  │  Output: JSON validado
  │
  ├── Se score < threshold → Alerta (Google Chat / email)
  ├── Salvar no Postgres (histórico de conferências)
  ├── Gerar relatório humano (template sobre o JSON)
  └── Métricas (Qdrant / dashboard)

O que NÃO usar JSON teórico do chat

O chat é valioso como contexto mas perigoso como fonte de dados estruturados.

Por que:

  • Mensagens informais não seguem padrão
  • “Manda pra clínica” pode significar 3 endereços diferentes
  • “Quero o mesmo de sempre” não é acionável sem histórico
  • Falsos positivos de divergência chat vs pedido geram fadiga de alerta

Em vez disso:

  • Chat vira chat_context (objeto separado no JSON)
  • Campos extracted são best-effort, sempre com raw_messages disponível
  • O relatório humano mostra como ℹ️, nunca como ⚠️ ou ❌
  • Se a atendente quiser formalizar algo do chat, ela altera o pedido
  • A conferência valida pedido vs OCR vs req (fontes formais)

Ganhos concretos

  1. Consistência — regras de sinônimo, ff↔base, ml≈g nunca são esquecidas
  2. Auditabilidade — cada conferência gera um JSON versionado e armazenável
  3. Métricas — “esta semana, 12% dos pedidos tiveram divergência de concentração”
  4. Escalabilidade — o validador roda em milissegundos, o LLM é o bottleneck
  5. Iterabilidade — adicionar regra nova = adicionar uma função, não reescrever prompt
  6. Alertas automáticos — score abaixo de X → notificação no Google Chat
  7. O prompt fica mais simples — LLM só extrai e mapeia, não decide