Regex + dicionário
Normaliza acentos, menus numéricos, confirmações, horários e padrões por estado.
Do webhook à resposta: uma leitura técnica do runtime, da NLU e das guardas que mantêm cada afirmação ancorada em dados reais.
O chatbot recebe o evento da Evolution, resolve o tenant, delega a execução ao backend de flows e devolve o texto pelo mesmo gateway.
O mesmo handler que extrai a mensagem chama
processEvolutionMessage() e finaliza em
sendEvolutionText(). Para tenants em flows, o corpo é
encaminhado a /webhook/:tenant.
apps/chatbot-whatsapp/server.js · L1745–1760 / L2593–2606 const replyText = await processEvolutionMessage({
tenantSlug,
from: remoteJid,
message: messageText,
event,
});
if (!replyText) {
return res.json({ ok: true, ignored: 'no_reply' });
}
await sendEvolutionText({
instanceName,
numberCandidates: buildEvolutionNumberCandidates(remoteJid),
text: replyText,
}); const url = `${String(FLOWS_BACKEND_URL).replace(/\/$/, '')}/webhook/${encodeURIComponent(effectiveTenantSlug)}`;
let response;
let raw = '';
try {
response = await fetchWithTimeout(
url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ From: String(from || ''), Body: String(message || '') }),
},
FLOWS_REQUEST_TIMEOUT_MS
); next é a aresta.Cada turno executa o handler do nó atual, combina variáveis, resolve a transição e persiste onde a conversa deve continuar.
{
"nodes": [
{
"id": "inicio",
"next": "saudacao_menu",
"type": "message",
"config": {
"text": ""
},
"position": {
"x": 250,
"y": 0
}
},
{
"id": "saudacao_menu",
"next": "menu_principal",
"type": "message",
"config": {
"text": "Oi! Seja bem-vindo à Barbearia do Léo 💈 Posso te ajudar a agendar um horário, ver valores ou tirar dúvidas. É só me dizer o que você precisa 👇"
},
"position": {
"x": 250,
"y": 50
}
},
{
"id": "menu_principal",
"next": {
"true": "menu_principal_assinante",
"false": "menu_principal_publico"
},
"type": "condition",
"config": {
"value": "true",
"operator": "equals",
"variable": "is_subscriber"
},
"position": {
"x": 250,
"y": 100
}
}
]
} private resolveNext(node: FlowNode, result: NodeResult): string | null {
// Jump / NLU router / confirm / fetch_slots (noSlotsNext) — use branch as target node ID
if (
(node.type === 'jump' || node.type === 'nlu_router' || node.type === 'confirm') &&
result.branch
) {
return result.branch;
}
// fetch_slots noSlotsNext — branch is a target node ID
if (node.type === 'fetch_slots' && result.branch) {
return result.branch;
}
// ai_agent exitConditions contain target node IDs directly.
if (node.type === 'ai_agent' && result.branch) {
const exitConditions = (node.config as { exitConditions?: Record<string, string> })
.exitConditions;
if (exitConditions && Object.values(exitConditions).includes(result.branch)) {
return result.branch;
}
}
// No next configured
if (node.next === null || node.next === undefined) {
return null;
}
// Simple string next
if (typeof node.next === 'string') {
return node.next;
}
// Conditional next (object with branches)
if (typeof node.next === 'object' && result.branch) {
return node.next[result.branch] || node.next['default'] || null;
}
return null;
} A interpretação começa sem custo e com alta precisão local. Só escala para modelo contextual quando regex e dicionário não resolvem.
Normaliza acentos, menus numéricos, confirmações, horários e padrões por estado.
Sonnet em contextos críticos; Haiku nos simples. Timeout de 3 segundos.
Baixa confiança vira unknown; o runtime pode pedir outra formulação.
// Layer 1: Regex
const regexResult = regexMatch(text, effectiveConfig);
if (regexResult) {
logger.debug('NLU regex match', {
state: effectiveConfig.state,
intent: regexResult.intent,
});
setCache(cacheKey, regexResult);
return regexResult;
}
// Layer 2: LLM
const llmResult = await llmInterpret(text, effectiveConfig);
if (llmResult && llmResult.confidence >= 0.6) {
logger.debug('NLU LLM match', {
state: effectiveConfig.state,
intent: llmResult.intent,
confidence: llmResult.confidence,
});
setCache(cacheKey, llmResult);
return llmResult;
}
// Layer 3: Low confidence / failure
const fallbackResult: NLUResult = {
intent: 'unknown',
entities: {},
confidence: llmResult?.confidence ?? 0,
originalText: text,
source: llmResult?.source ?? 'regex',
};
return fallbackResult;
A terceira camada retorna intenção unknown e preserva a
confiança do modelo. As mensagens de clarificação também vivem no
serviço, separadas da classificação.
claude-sonnet-4-20250514claude-haiku-4-5-20251001nlu.service.ts · L61–80 / L1083–1086 O manifesto descreve identidade, intents, integrações e onboarding. O flow descreve o comportamento transacional.
manifest.tscapacidade + configuraçãoflow.jsongrafo + transiçõesimport type { VerticalManifest } from '@/types/vertical-manifest';
export const manifest: VerticalManifest = {
slug: 'outsourcing',
name: 'Outsourcing de Impressão e Informática',
industry: 'outsourcing',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
schemaPrefix: 'os_',
useGenericTables: false,
defaultFlowPath: 'apps/verticals/outsourcing/flow.json',
customIntents: [
'novo_chamado',
'status_chamado',
'solicitar_suprimento',
'agendar_manutencao',
'consultar_contrato',
],
intentsPath: 'apps/verticals/outsourcing/nlu-intents.ts',
messagesPath: 'apps/verticals/outsourcing/messages.ts',
integrations: [
{
name: 'printwayy',
type: 'erp',
required: false,
configFields: [
{ key: 'api_key', label: 'API Key Printwayy', type: 'secret', required: true },
{ key: 'company_id', label: 'Company ID', type: 'string', required: true },
],
adapterPath: 'apps/verticals/outsourcing/integrations/printwayy.ts',
},
],
customPages: [],
onboardingFields: [
{ key: 'company_name', label: 'Nome da Empresa', type: 'string', required: true },
{ key: 'whatsapp', label: 'WhatsApp', type: 'phone', required: true },
{ key: 'main_contact', label: 'Contato Principal', type: 'string', required: true },
],
ragAutoIndex: true,
ragDocsPath: 'apps/verticals/outsourcing/skills',
};
export default manifest; Tier 1 produz sinais isolados. Tier 2 cruza concentração, máquinas stale, piso contratual, suprimentos e tendência de volume.
concentracao_stale Cliente é rank 1 ou > 50% do volume + ao menos uma máquina stale.
stale_suprimento Máquina stale + consumível com nível conhecido entre 0% e < 10%.
piso_volume Cliente no piso + série suficiente + volume em alta.
concentracao_queda_volume Cliente concentrado + série suficiente em queda + gatilho churn ligado.
export function avaliarCorrelacao(
stale: StaleMachineInsight[],
concentracao: ClientConcentrationInsight[],
piso: SubutilizacaoPisoInsight[],
options: CorrelacaoOptions = {}
): CorrelacaoEvaluation {
const audit: CorrelacaoAuditEvent[] = [];
const volumeTrends = options.volumeTrends ?? [];
const correlacoes = [
...concentrationStaleRule(concentracao, stale),
...staleSupplyRule(stale, options.criticalSupplies ?? []),
...pisoVolumeRule(piso, volumeTrends, audit),
...churnRule(concentracao, volumeTrends, options.churnEnabled === true, audit),
].sort((left, right) => left.priority - right.priority);
return { correlacoes, audit };
} 🌙Antes de tudo: olhe RIS primeiro.
Porque RIS concentra 59,8% do volume e há máquina sem comunicação há 327 dias, com 1 críticos no radar, esse é o risco que mais conecta operação e carteira.
Próximo passo: vale uma ligação para confirmar se a máquina foi substituída ou se é problema de comunicação.
src/__tests__/correlacao-tier2.test.ts.
Identidade pode ser mascarada. Ausência permanece ausência. Narração que inventa número, omite âncora ou promete ação fantasma cai para fallback determinístico.
Identidades viram pseudônimos; os números operacionais continuam reais.
const scrub = (value: unknown): unknown => {
if (typeof value === 'string') {
const exactPseudonym = replacements.get(value);
if (exactPseudonym) return exactPseudonym;
return orderedReplacements.reduce(
(masked, [real, pseudonym]) => masked.replaceAll(real, pseudonym),
value
);
}
if (Array.isArray(value)) return value.map(scrub);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, scrub(nested)])
);
}
return value;
};
const masked = scrub(source) as FleetSnapshot;
masked.tenant = { id: 'parque-real-mascarado', name: 'Parque real mascarado' };
masked.presentation = {
source: 'masked',
time_zone: OUTSOURCING_DASHBOARD_TIME_ZONE,
captured_at: counterCapturedAt.toISOString(),
identities_masked: true,
operational_numbers_real: true,
}; -1, faixa inválida e 0 de peça não viram nível operacional.
export function normalizeSupplyLevel(
rawLevel: unknown,
type?: unknown,
levelDescription?: unknown
): number | null {
if (normalizeText(levelDescription) === 'desconhecido') return null;
const value = numberValue(rawLevel);
if (value === null) return null;
if (value === SENTINEL_ESTIMATED_LEVEL_UNKNOWN) return null;
if (value < 0 || value > 100) return null;
if (value === 0 && !isConsumivel(type)) return null;
return value;
} Número sem grounding, âncora ausente ou promessa fantasma aciona fallback.
const phantom = text ? phantomActionOffers(text) : [];
if (phantom.length > 0) {
logger.warn('B2B cockpit Sonnet narration phantom action fallback', {
intent: input.intent,
phantom,
});
return withLunarPrefix(input.fallbackNarrative);
}
const ungrounded = text ? ungroundedNumbers(text, input.structuredInsight) : [];
if (ungrounded.length > 0) {
logger.warn('B2B cockpit Sonnet narration ungrounded number fallback', {
intent: input.intent,
ungrounded,
});
return withLunarPrefix(input.fallbackNarrative);
}
const missing = text ? missingAnchors(text, input.structuredInsight) : [];
if (missing.length > 0) {
logger.warn('B2B cockpit Sonnet narration missing anchor fallback', {
intent: input.intent,
missing,
});
return withLunarPrefix(input.fallbackNarrative);
}
return text ? withLunarPrefix(text) : withLunarPrefix(input.fallbackNarrative); Dado desconhecido não é zero. Texto plausível não é evidência. Fallback honesto é parte do produto.
voltar ao topoA superfície responde “está tudo bem?” com poucos sinais. A profundidade fica com a Luna — sem romper a cadeia entre origem, agregação, janela e exibição.
O provider escolhe o contrato. A tela declara se o snapshot é autenticado, real mascarado ou fictício de demonstração.
src/App.tsx · L264–273 e src/views/TrustStrip.tsx · L69–102 export type DashboardDataMode = 'fixture' | 'masked' | 'authenticated';
const runtimeFixture =
typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).get('dados') === 'fixture';
const providerFlag = import.meta.env.VITE_DASHBOARD_PROVIDER;
export const dashboardDataMode: DashboardDataMode = providerFlag === 'real'
? 'authenticated'
: providerFlag === 'fixture' || runtimeFixture
? 'fixture'
: 'masked'; O componente recebe estado pronto e emite a mensagem. Endpoint, autenticação e escolha de fonte ficam fora da camada visual.
export function LunaChat({
messages,
busy,
error,
onSend,
compact = false,
}: {
messages: LunaMessage[];
busy: boolean;
error: string | null;
onSend: (message: string) => Promise<void>;
compact?: boolean;
}) { Ação é dado declarativo, executor é plugável e consequência externa exige confirmação. Ajuste de estoque existe no catálogo — mas permanece desativado.
{
tipo: 'estoque.ajustar',
enabled: false,
reversible: true,
requires_confirmation: true,
}