From 7592f332c7c1940eb610570d01d18154366c85d0 Mon Sep 17 00:00:00 2001
From: webtecnica
Date: Wed, 30 Sep 2026 10:18:29 -0300
Subject: [PATCH] =?UTF-8?q?feat(agent):=20atraso=20humano=20configur=C3=A1?=
=?UTF-8?q?vel=20por=20conex=C3=A3o=20(0499)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Os quatro números que governam o atraso humano ANTES da primeira bolha
(atraso-humano.ts: NOTAR 900ms, POR_CARACTERE 22ms, MINIMO 1200ms, MAXIMO
7500ms) eram literais no módulo, sem superfície. Uma clínica e um e-commerce
querem ritmos diferentes e recebiam sempre o mesmo valor cravado (issue #653).
Os quatro saem do literal e passam a ser 4 colunas NULL em channel_knobs — a
MESMA tabela dos knobs de pacing por conexão (0010/0495); NULL cai no default
conservador em defaults.ts que espelha os valores de antes (regressão zero).
Exposição na ficha Anti-ban via /api/v1/ai/pacing (POST/GET), com CHECK de
sanidade (max >= min, teto intervalMaxMs).
O jitter entre bolhas (literal 1200 + rand*800 em inbound-turn.ts) NÃO ganha
coluna: ele já é throttle_ms + jitter_max_ms (defaults 1200/800) e o call site
passa a LER os knobs da conexão em vez do literal.
DoD: migration 0499 + apêndice idempotente no baseline + linha no MANIFEST;
leitor testado (default, null -> default, knobs da conexão), regressão zero em
calcularAtrasoHumano; teste novo vermelho sem a mudança (sabotagem: 4 falharam).
---
app/api/v1/ai/pacing/route.ts | 6 +-
components/connections/AntiBanSheet.tsx | 84 ++++++++++++++
hooks/channels/usePacingKnobs.ts | 2 +
lib/agent-engine/agent/atraso-humano.ts | 40 ++++++-
lib/agent-engine/agent/inbound-turn.ts | 24 +++-
lib/agent-engine/pacing/defaults.ts | 24 ++++
lib/agent-engine/pacing/store.ts | 10 ++
lib/ai/pacing-knobs.ts | 14 +++
lib/automation/janela-do-canal.ts | 6 +
lib/database.types.ts | 12 ++
lib/i18n/dicionario.ts | 12 ++
supabase/baseline.sql | 37 ++++++
...0160000_0499_atraso_humano_por_conexao.sql | 52 +++++++++
supabase/migrations/MANIFEST.md | 1 +
tests/unit/atraso-humano-knobs.test.ts | 107 ++++++++++++++++++
...raso-humano-nao-afrouxa-o-anti-ban.test.ts | 9 +-
.../protecao-de-envio-abre-e-avisa.test.tsx | 6 +
17 files changed, 436 insertions(+), 10 deletions(-)
create mode 100644 supabase/migrations/20260930160000_0499_atraso_humano_por_conexao.sql
create mode 100644 tests/unit/atraso-humano-knobs.test.ts
diff --git a/app/api/v1/ai/pacing/route.ts b/app/api/v1/ai/pacing/route.ts
index dfedff39f..e5f45c36d 100644
--- a/app/api/v1/ai/pacing/route.ts
+++ b/app/api/v1/ai/pacing/route.ts
@@ -31,7 +31,7 @@ export const dynamic = "force-dynamic";
const KNOB_COLUMNS =
// As duas janelas entram no SELECT: sem `resposta_*` aqui, a ficha Anti-ban
// mostraria 7h-22h como se fosse a janela da resposta — e é a de DISPARO.
- "throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at";
+ "throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at, atraso_notar_ms, ms_por_caractere, atraso_minimo_ms, atraso_maximo_ms";
/**
* `organizations.timezone`, para a tela mostrar o fuso em que o motor avalia a
@@ -167,6 +167,10 @@ export async function PUT(req: NextRequest): Promise {
window_end_hour: null,
resposta_start_hour: null,
resposta_end_hour: null,
+ atraso_notar_ms: null,
+ ms_por_caractere: null,
+ atraso_minimo_ms: null,
+ atraso_maximo_ms: null,
allow_sunday: null,
timezone: null,
warmup_daily_caps: null,
diff --git a/components/connections/AntiBanSheet.tsx b/components/connections/AntiBanSheet.tsx
index 63ad82b41..51728089f 100644
--- a/components/connections/AntiBanSheet.tsx
+++ b/components/connections/AntiBanSheet.tsx
@@ -42,6 +42,11 @@ interface FormState {
resposta_end_hour: string;
throttle_s: string;
jitter_s: string;
+ /** Atraso humano antes da 1ª bolha (0499). '' = usar o padrão. */
+ atraso_notar_ms: string;
+ ms_por_caractere: string;
+ atraso_minimo_ms: string;
+ atraso_maximo_ms: string;
daily_message_limit: string;
allow_sunday: boolean;
timezone: string;
@@ -59,6 +64,10 @@ function fromItem(item: PacingKnobsItem): FormState {
resposta_end_hour: o?.resposta_end_hour != null ? String(o.resposta_end_hour) : "",
throttle_s: o?.throttle_ms != null ? String(o.throttle_ms / 1000) : "",
jitter_s: o?.jitter_max_ms != null ? String(o.jitter_max_ms / 1000) : "",
+ atraso_notar_ms: o?.atraso_notar_ms != null ? String(o.atraso_notar_ms) : "",
+ ms_por_caractere: o?.ms_por_caractere != null ? String(o.ms_por_caractere) : "",
+ atraso_minimo_ms: o?.atraso_minimo_ms != null ? String(o.atraso_minimo_ms) : "",
+ atraso_maximo_ms: o?.atraso_maximo_ms != null ? String(o.atraso_maximo_ms) : "",
daily_message_limit:
item.channel_session.daily_message_limit != null
? String(item.channel_session.daily_message_limit)
@@ -147,6 +156,10 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
resposta_end_hour: intOrNull(form.resposta_end_hour),
throttle_ms: msOrNull(form.throttle_s),
jitter_max_ms: msOrNull(form.jitter_s),
+ atraso_notar_ms: intOrNull(form.atraso_notar_ms),
+ ms_por_caractere: intOrNull(form.ms_por_caractere),
+ atraso_minimo_ms: intOrNull(form.atraso_minimo_ms),
+ atraso_maximo_ms: intOrNull(form.atraso_maximo_ms),
// `null` quando o Switch está no default: salvar esta ficha por outro
// motivo (aquecimento, throttle) não pode congelar o padrão do dia como
// escolha permanente — foi assim que uma instalação ficou muda todo
@@ -356,6 +369,77 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
+
+