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