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) {

+
+ +
+ + + + +
+

+ {t( + "Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — aquecido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.", + )} +

+
+
{ + return { + atrasoNotarMs: knobs?.atrasoNotarMs ?? ATRASO_NOTAR_MS, + msPorCaractere: knobs?.msPorCaractere ?? MS_POR_CARACTERE, + atrasoMinimoMs: knobs?.atrasoMinimoMs ?? ATRASO_MINIMO_MS, + atrasoMaximoMs: knobs?.atrasoMaximoMs ?? ATRASO_MAXIMO_MS, + }; +} + /** * Quanto esperar antes de mandar `texto`, em ms. Pura — é o que a torna - * testável sem relógio e sem canal. + * testável sem relógio e sem canal. `knobs` opcional: sem eles vale + * EXATAMENTE o comportamento histórico (regressão zero). */ -export function calcularAtrasoHumano(texto: string): number { +export function calcularAtrasoHumano(texto: string, knobs?: AtrasoHumanoKnobs): number { + const ef = atrasoHumanoEfetivo(knobs); const comprimento = (texto ?? '').trim().length; - const bruto = ATRASO_NOTAR_MS + MS_POR_CARACTERE * comprimento; - return Math.min(ATRASO_MAXIMO_MS, Math.max(ATRASO_MINIMO_MS, bruto)); + const bruto = ef.atrasoNotarMs + ef.msPorCaractere * comprimento; + return Math.min(ef.atrasoMaximoMs, Math.max(ef.atrasoMinimoMs, bruto)); } export interface EsperaHumanaArgs { @@ -78,6 +101,11 @@ export interface EsperaHumanaArgs { processamentoMs?: number; sleep: (ms: number) => Promise; log: Logger; + /** + * Os quatro números do atraso por conexão (channel_knobs 0499). Omitir = + * comportamento histórico (defaults de atraso-humano.ts). + */ + knobs?: AtrasoHumanoKnobs; /** * Acende o "digitando…" no aparelho do cliente. OPCIONAL: canal que não sabe * sinalizar presença simplesmente espera, e o ganho principal (não responder @@ -101,7 +129,7 @@ export async function esperarComoHumano(args: EsperaHumanaArgs): Promise const gasto = args.processamentoMs ?? 0; const ms = Math.max( 0, - calcularAtrasoHumano(args.texto) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0), + calcularAtrasoHumano(args.texto, args.knobs) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0), ); // O cliente já esperou o alvo: não acrescentar presença decorativa nem sleep. diff --git a/lib/agent-engine/agent/inbound-turn.ts b/lib/agent-engine/agent/inbound-turn.ts index ee2572726..5566112bb 100644 --- a/lib/agent-engine/agent/inbound-turn.ts +++ b/lib/agent-engine/agent/inbound-turn.ts @@ -2620,6 +2620,19 @@ async function executarTurnoDoAgente( // turno com dois vetos ficaria mudo por mais de 20 segundos: o conserto do // "rápido demais" viraria o defeito simétrico, mais caro que o original. let jaEsperouComoHumano = false; + // Knobs de atraso humano por conexão (0499) — lidos UMA vez por turno para a + // pausa antes da 1ª bolha (`esperarComoHumano`) e para o jitter entre bolhas + // (`throttle_ms + jitter_max_ms`). Sem linha em channel_knobs caem nos defaults + // de defaults.ts — que espelham os valores históricos (regressão zero). + const pacingDoTurno = !preview + ? await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog) + : null; + const knobsDeAtrasoHumano = { + atrasoNotarMs: pacingDoTurno?.knobs.atrasoNotarMs, + msPorCaractere: pacingDoTurno?.knobs.msPorCaractere, + atrasoMinimoMs: pacingDoTurno?.knobs.atrasoMinimoMs, + atrasoMaximoMs: pacingDoTurno?.knobs.atrasoMaximoMs, + }; // Cap de envio (warm-up/diário) vetado neste turno — capturado aqui porque o veto // não empurra outcome nenhum a `outcomes` (ver comentário no ponto de captura, mais // abaixo). Diferente da janela horária (checada ANTES do modelo rodar, linha ~1233): @@ -3169,6 +3182,9 @@ async function executarTurnoDoAgente( agentConfig?.splitMaxChars ?? 600, Math.max(1, maxSendsPerTurn - seq), )[0] ?? body, + // Os quatro números do atraso por conexão (0499). Vazios (sem + // linha em channel_knobs / preview) = defaults históricos. + knobs: knobsDeAtrasoHumano, // `processamentoMs` é a contribuição do #849 (@Teowfb): a pausa humana desconta o // tempo que o turno JÁ gastou pensando, em vez de somar em cima dele. Sem este // argumento o `gasto` de `atraso-humano.ts` cai no `?? 0` e o desconto não acontece — @@ -3192,7 +3208,13 @@ async function executarTurnoDoAgente( corposEnviados.push(finalBody); const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - const jitter = () => 1200 + Math.floor(Math.random() * 800); // piso no throttle anti-ban (1.2s) — bolhas são mensagens físicas + const jitter = () => + // Piso no throttle anti-ban do número (1.2s default) — bolhas são + // mensagens físicas. Lê os knobs da CONEXÃO (throttle_ms + jitter_max_ms, + // 0499) e não o literal: desde a 0010 os dois já são configuração por + // número, e o call site estava cravando `1200 + rand*800` ignorando-a. + (pacingDoTurno?.knobs.throttleMs ?? 1200) + + Math.floor(Math.random() * (pacingDoTurno?.knobs.jitterMaxMs ?? 800)); const enviar = ( corpo: string, media?: FotoParaEnvio, diff --git a/lib/agent-engine/pacing/defaults.ts b/lib/agent-engine/pacing/defaults.ts index d7a0a2c5e..1cb12f7f2 100644 --- a/lib/agent-engine/pacing/defaults.ts +++ b/lib/agent-engine/pacing/defaults.ts @@ -21,6 +21,19 @@ export interface PacingKnobs { throttleMs: number; /** Teto do jitter randômico somado ao throttle e ao next_allowed_at (ms) — intervalo fixo é assinatura de bot. */ jitterMaxMs: number; + /** + * Parcela FIXA do atraso humano antes da 1ª bolha (ms): ver a notificação, + * abrir a conversa, ler o que o cliente escreveu. Existe separada do termo + * proporcional porque mesmo um "Sim!" tem esse custo (atraso-humano.ts). + * NULL em channel_knobs = este default. + */ + atrasoNotarMs: number; + /** Taxa de digitação do atraso humano (ms por caractere) — default 22ms ≈ 45 c/s. NULL = default. */ + msPorCaractere: number; + /** Piso do atraso humano (ms) — abaixo dele o atraso não significa nada. NULL = default. */ + atrasoMinimoMs: number; + /** Teto do atraso humano (ms) — acima dele o silêncio lê como queda. NULL = default. */ + atrasoMaximoMs: number; /** * Janela horária de DISPARO [start, end) na hora local do tenant — vale para o * disparo em massa (`lib/prospecting/worker.ts`) e para a retomada de conversa @@ -74,6 +87,10 @@ export interface PacingKnobs { export const KNOB_BOUNDS = { /** teto de intervalo/jitter aceito na UI (ms). */ intervalMaxMs: 600_000, + /** teto de ms_por_caractere aceito na UI (ms) — 202ms ≈ 5 c/s é absurdamente lento; nada real precisa de mais. */ + msPorCaractereMax: 200, + /** teto do atraso máximo aceito na UI (ms) — o piso é o default do anti-ban (1200). */ + atrasoMaximoMsMax: 60_000, /** maior hora aceita como INÍCIO de janela (fim vai até 24). */ hourLastStart: 23, /** fim de janela é exclusivo e pode chegar à meia-noite seguinte. */ @@ -83,6 +100,13 @@ export const KNOB_BOUNDS = { export const PACING_DEFAULTS: PacingKnobs = { throttleMs: 1200, // 1 msg / 1,2s jitterMaxMs: 800, + // Números do atraso humano antes da 1ª bolha (atraso-humano.ts). Espelham os + // valores que foram SEMPRE os literais do módulo — regressão zero para quem + // nunca configurou. Coluna em channel_knobs NULL cai aqui. + atrasoNotarMs: 900, + msPorCaractere: 22, + atrasoMinimoMs: 1200, + atrasoMaximoMs: 7500, windowStartHour: 7, // janela 7h-22h windowEndHour: 22, // Espelha a janela de disparo: quem nunca gravou as colunas `resposta_*` diff --git a/lib/agent-engine/pacing/store.ts b/lib/agent-engine/pacing/store.ts index e8de2ec90..a4afad58d 100644 --- a/lib/agent-engine/pacing/store.ts +++ b/lib/agent-engine/pacing/store.ts @@ -20,6 +20,11 @@ interface ChannelKnobsRow { /** Janela da RESPOSTA do agente (0495). NULL = usa `window_*` (comportamento anterior). */ resposta_start_hour: number | null; resposta_end_hour: number | null; + /** Números do atraso humano antes da 1ª bolha (0499). NULL = default (defaults.ts). */ + atraso_notar_ms: number | null; + ms_por_caractere: number | null; + atraso_minimo_ms: number | null; + atraso_maximo_ms: number | null; allow_sunday: boolean | null; timezone: string | null; warmup_daily_caps: unknown; // jsonb — shape validado em parseWarmupCaps (nunca confiado) @@ -96,6 +101,7 @@ export async function loadChannelKnobs( const { rows } = await db.query( `select k.throttle_ms, k.jitter_max_ms, k.window_start_hour, k.window_end_hour, k.resposta_start_hour, k.resposta_end_hour, + k.atraso_notar_ms, k.ms_por_caractere, k.atraso_minimo_ms, k.atraso_maximo_ms, k.allow_sunday, k.timezone, k.warmup_daily_caps, k.number_activated_at, o.timezone as org_timezone from organizations o @@ -126,6 +132,10 @@ export async function loadChannelKnobs( knobs: { throttleMs: row.throttle_ms ?? PACING_DEFAULTS.throttleMs, jitterMaxMs: row.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs, + atrasoNotarMs: row.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs, + msPorCaractere: row.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere, + atrasoMinimoMs: row.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs, + atrasoMaximoMs: row.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs, windowStartHour: row.window_start_hour ?? PACING_DEFAULTS.windowStartHour, windowEndHour: row.window_end_hour ?? PACING_DEFAULTS.windowEndHour, // `null` nestas duas = o número nunca foi configurado com janela de diff --git a/lib/ai/pacing-knobs.ts b/lib/ai/pacing-knobs.ts index 1bc4f0648..a1b801142 100644 --- a/lib/ai/pacing-knobs.ts +++ b/lib/ai/pacing-knobs.ts @@ -111,6 +111,11 @@ export const pacingKnobsUpdateSchema = z channel_session_id: z.string().uuid(), throttle_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(), jitter_max_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(), + // Atraso humano antes da 1ª bolha (0499). null = voltar ao default (defaults.ts). + atraso_notar_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(), + ms_por_caractere: z.number().int().min(0).max(KNOB_BOUNDS.msPorCaractereMax).nullable().optional(), + atraso_minimo_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(), + atraso_maximo_ms: z.number().int().min(0).max(KNOB_BOUNDS.atrasoMaximoMsMax).nullable().optional(), window_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(), window_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(), /** @@ -174,6 +179,11 @@ export interface ChannelKnobsRow { /** Janela da RESPOSTA (0495). Ausente/null = herda a janela de disparo. */ resposta_start_hour?: number | null; resposta_end_hour?: number | null; + /** Atraso humano antes da 1ª bolha (0499). Ausente/null = default. */ + atraso_notar_ms?: number | null; + ms_por_caractere?: number | null; + atraso_minimo_ms?: number | null; + atraso_maximo_ms?: number | null; allow_sunday: boolean | null; timezone: string | null; warmup_daily_caps: unknown; @@ -200,6 +210,10 @@ export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string | return { throttleMs: row?.throttle_ms ?? PACING_DEFAULTS.throttleMs, jitterMaxMs: row?.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs, + atrasoNotarMs: row?.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs, + msPorCaractere: row?.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere, + atrasoMinimoMs: row?.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs, + atrasoMaximoMs: row?.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs, windowStartHour: row?.window_start_hour ?? PACING_DEFAULTS.windowStartHour, windowEndHour: row?.window_end_hour ?? PACING_DEFAULTS.windowEndHour, // Mesma regra do store do engine: coluna vazia herda a janela de DISPARO. diff --git a/lib/automation/janela-do-canal.ts b/lib/automation/janela-do-canal.ts index 648eedaba..9e470839c 100644 --- a/lib/automation/janela-do-canal.ts +++ b/lib/automation/janela-do-canal.ts @@ -89,6 +89,12 @@ export async function knobsDoCanal( return { throttleMs: linha.throttle_ms ?? PACING_DEFAULTS.throttleMs, jitterMaxMs: linha.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs, + // Automação é disparo em massa — o atraso humano (0499) não se aplica, mas + // o tipo PacingKnobs pede os quatro; herdam o default conservador. + atrasoNotarMs: PACING_DEFAULTS.atrasoNotarMs, + msPorCaractere: PACING_DEFAULTS.msPorCaractere, + atrasoMinimoMs: PACING_DEFAULTS.atrasoMinimoMs, + atrasoMaximoMs: PACING_DEFAULTS.atrasoMaximoMs, windowStartHour: linha.window_start_hour ?? PACING_DEFAULTS.windowStartHour, windowEndHour: linha.window_end_hour ?? PACING_DEFAULTS.windowEndHour, // Automação é disparo: nunca lê a janela de resposta (0495). O par espelha o diff --git a/lib/database.types.ts b/lib/database.types.ts index b11ef833b..0191f777a 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -3951,6 +3951,10 @@ export type Database = { warmup_daily_caps: Json | null window_end_hour: number | null window_start_hour: number | null + atraso_notar_ms: number | null + ms_por_caractere: number | null + atraso_minimo_ms: number | null + atraso_maximo_ms: number | null } Insert: { allow_sunday?: boolean | null @@ -3967,6 +3971,10 @@ export type Database = { warmup_daily_caps?: Json | null window_end_hour?: number | null window_start_hour?: number | null + atraso_notar_ms?: number | null + ms_por_caractere?: number | null + atraso_minimo_ms?: number | null + atraso_maximo_ms?: number | null } Update: { allow_sunday?: boolean | null @@ -3983,6 +3991,10 @@ export type Database = { warmup_daily_caps?: Json | null window_end_hour?: number | null window_start_hour?: number | null + atraso_notar_ms?: number | null + ms_por_caractere?: number | null + atraso_minimo_ms?: number | null + atraso_maximo_ms?: number | null } Relationships: [ { diff --git a/lib/i18n/dicionario.ts b/lib/i18n/dicionario.ts index ae14fa17d..8aa5e72dd 100644 --- a/lib/i18n/dicionario.ts +++ b/lib/i18n/dicionario.ts @@ -5259,6 +5259,18 @@ export const DICIONARIO: Traducoes = { "Intervalo mínimo entre mensagens do mesmo número, mais uma variação aleatória — ritmo cravado parece robô para o WhatsApp.": { es: "Intervalo mínimo entre mensajes del mismo número, más una variación aleatoria. Un ritmo siempre igual hace que WhatsApp lo detecte como un robot.", }, + "Atraso humano antes da primeira resposta (ms)": { + es: "Retraso humano antes de la primera respuesta (ms)", + }, + "Ver a notificação": { es: "Ver la notificación" }, + "Tempo para ver a notificação em ms": { es: "Tiempo para ver la notificación en ms" }, + "Por caractere": { es: "Por carácter" }, + "Milissegundos por caractere da resposta": { es: "Milisegundos por carácter de la respuesta" }, + "Atraso humano mínimo em ms": { es: "Retraso humano mínimo en ms" }, + "Atraso humano máximo em ms": { es: "Retraso humano máximo en ms" }, + "Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — aquecido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.": { + es: "Cuánto \"piensa\" el agente antes de enviar la primera respuesta (ver la notificación + escritura por carácter, limitado entre un mínimo y un máximo). Campo vacío usa el estándar: demasiado rápido parece robot; demasiado lento parece que se cayó. No cambia el intervalo entre mensajes.", + }, "Teto diário de envios": { es: "Tope diario de envíos" }, "sem teto definido": { es: "sin tope definido" }, "Teto diário de mensagens": { es: "Tope diario de mensajes" }, diff --git a/supabase/baseline.sql b/supabase/baseline.sql index df5efe542..8c5182c3e 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -45213,6 +45213,43 @@ alter table public.channel_knobs and (resposta_end_hour is null or resposta_end_hour between 1 and 24) ); +-- ---- atraso humano configurável por conexão (migration 0499) ---- +-- 0499 — os quatro números do atraso humano ANTES da primeira bolha +-- (`atraso-humano.ts`) viram knob por conexão (issue #653): NOTAR, POR_CARACTERE, +-- MINIMO e MAXIMO, todos NULL = default em defaults.ts (900/22/1200/7500 — os +-- valores de antes, regressão zero). Nascem em `channel_knobs`, a mesma tabela +-- dos knobs de pacing por conexão (0010 e 0495); nada de tabela nova. O jitter +-- entre bolhas não ganha coluna: ele já é `throttle_ms` + `jitter_max_ms`. +-- Reaplicável: `add column if not exists` e `drop constraint if exists` antes +-- do `add constraint` — o `update.sh` roda o apêndice inteiro a cada atualização. +alter table public.channel_knobs + drop constraint if exists channel_knobs_atraso_humano_saneamento; + +alter table public.channel_knobs + add column if not exists atraso_notar_ms integer, + add column if not exists ms_por_caractere integer, + add column if not exists atraso_minimo_ms integer, + add column if not exists atraso_maximo_ms integer; + +comment on column public.channel_knobs.atraso_notar_ms is + 'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).'; +comment on column public.channel_knobs.ms_por_caractere is + 'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).'; +comment on column public.channel_knobs.atraso_minimo_ms is + 'Piso do atraso humano (ms). NULL = default (1200).'; +comment on column public.channel_knobs.atraso_maximo_ms is + 'Teto do atraso humano (ms). NULL = default (7500).'; + +alter table public.channel_knobs + add constraint channel_knobs_atraso_humano_saneamento + check ( + (atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000)) + and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000)) + and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000)) + and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000)) + and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms) + ); + -- ---- dedupe de event_dead atômico: índice único parcial (migration 0491) ---- -- 0491 — o aviso `event_dead` não abre em dobro com dois drenos concorrentes -- (issue #880). O dedupe era uma pergunta seguida de uma escrita: `lib/event-log/ diff --git a/supabase/migrations/20260930160000_0499_atraso_humano_por_conexao.sql b/supabase/migrations/20260930160000_0499_atraso_humano_por_conexao.sql new file mode 100644 index 000000000..06e257c75 --- /dev/null +++ b/supabase/migrations/20260930160000_0499_atraso_humano_por_conexao.sql @@ -0,0 +1,52 @@ +-- ═══ Atraso humano configurável por conexão (0499) ═══ +-- +-- Os quatro números que governam o atraso humano ANTES da primeira bolha +-- (`lib/agent-engine/agent/atraso-humano.ts`) eram fixos em código: NOTAR +-- (~900ms), POR_CARACTERE (~22ms), MINIMO (~1200ms) e MAXIMO (~7500ms), sem +-- superfície. Uma clínica e um e-commerce querem ritmos diferentes, e os dois +-- recebiam o mesmo valor cravado (issue #653, do achado no PR #644). +-- +-- Estes quatro knobs nascem em `channel_knobs` — a MESMA tabela que já guarda +-- `throttle_ms`/`jitter_max_ms` (0010) e `resposta_*` (0495) por conexão. +-- Coluna NULL = cai no default conservador em `defaults.ts`, que espelha os +-- valores de antes: regressão zero para quem não configurou. +-- +-- Nenhuma migration nova se o mecanismo knob-por-conexão já existisse — ele +-- existe, e é `channel_knobs`. Estes são só quatro colunas novas na tabela +-- existente, com o MESMO contrato das vizinhas (nullable, sem default). +-- +-- O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha +-- coluna nova: ele já é `throttle_ms + jitter_max_ms` (defaults 1200/800) — +-- o que mudou foi o call site passar a LER os knobs da conexão em vez do +-- literal. O preenchimento acima e a exposição na ficha Anti-ban valem para +-- o par. + +alter table public.channel_knobs + drop constraint if exists channel_knobs_atraso_humano_saneamento; + +alter table public.channel_knobs + add column if not exists atraso_notar_ms integer, + add column if not exists ms_por_caractere integer, + add column if not exists atraso_minimo_ms integer, + add column if not exists atraso_maximo_ms integer; + +comment on column public.channel_knobs.atraso_notar_ms is + 'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).'; +comment on column public.channel_knobs.ms_por_caractere is + 'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).'; +comment on column public.channel_knobs.atraso_minimo_ms is + 'Piso do atraso humano (ms). NULL = default (1200).'; +comment on column public.channel_knobs.atraso_maximo_ms is + 'Teto do atraso humano (ms). NULL = default (7500).'; + +-- Sanidade: valores positivos e dentro do mesmo teto dos vizinhos (intervalMaxMs). +-- O `drop … if exists` antes do `add` é o que torna o apêndice reaplicável. +alter table public.channel_knobs + add constraint channel_knobs_atraso_humano_saneamento + check ( + (atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000)) + and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000)) + and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000)) + and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000)) + and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms) + ); \ No newline at end of file diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index a89375368..0f00ff003 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -485,3 +485,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order | `20260930010300` | `0494_lgpd_cascata_banco_alcanca_notas_itens` | **A cascata do BANCO alcança `lead_notes`, `ai_agent_runs.tool_calls`, `lead_state` e `contacts.social_identity` (issue #1964, follow-up do #1958).** O gatilho da virada de `is_anonymized` (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: `lead_notes.headline`/`body` (e `embedding`) → `(anonimizado)`, `ai_agent_runs.tool_calls` → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha `redigirToolCalls`/`serialize.ts`, com `redacted = true` em todo passo), `lead_state.next_action` → `null` e `qualification` → `{}`, e `contacts.social_identity` → `null` (o índice único parcial `where social_identity is not null` torna anular seguro). Filtra organização **e** contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. `fn_lgpd_redigir_tool_calls(jsonb)` é função nova `immutable` revogada de `anon`/`authenticated` (único chamador é o gatilho, `security definer` de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no `baseline.sql` antes da VARREDURA anon. Gate: `tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts`. | | `20260930120000` | `0495_janela_de_resposta_separada` | **A janela de RESPOSTA sai da janela de DISPARO (#1983, de @suporteubere99-coder).** As duas coisas eram regidas por UM par de horas em `channel_knobs` (`window_start_hour`/`window_end_hour`, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa, a prospecção e o `followup_turn` (retomada de conversa parada). Duas colunas nullable, `resposta_start_hour`/`resposta_end_hour`, sem DEFAULT: **NULL herda a janela de disparo, coluna a coluna** (`loadChannelKnobs` em `lib/agent-engine/pacing/store.ts` e `effectiveKnobs` em `lib/ai/pacing-knobs.ts`, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com `drop … if exists` antes do `add`. Um bloco `do` guardado renomeia `reengajar_start_hour`/`reengajar_end_hour` para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em `PacingInput.resposta` (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só `inbound_turn`/`case_reply_turn` passam `true` (`eTurnoDeResposta` em `inbound-turn.ts`), inclusive na pergunta do roteiro que sai no mesmo turno. `nextDayOpen` segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` e `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (0/24 aceito, 22/7 recusado com 422). | | `20260930140000` | `0497_lgpd_anonimizar_apaga_transcricao` | **Anonimizar apaga também a TRANSCRIÇÃO da mídia (`messages.media_derived_text`).** Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo `media-derive-worker`) não era zerada por nenhum caminho: o body do áudio virava `[mensagem anonimizada]` e a transcrição continuava legível para o agente. Uma linha `media_derived_text = null` em cada UPDATE de messages de `fn_redigir_conversas_ao_anonimizar` (corpo da 0494, 2 passadas) e de `fn_lgpd_cascade_redact_contact` (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de `lib/lgpd/cascata.ts` ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. | +| `20260930160000` | `0499_atraso_humano_por_conexao` | **O atraso humano vira configuração por conexão (issue #653).** Os quatro números que governam o atraso ANTES da primeira bolha (`atraso_notar_ms`/`ms_por_caractere`/`atraso_minimo_ms`/`atraso_maximo_ms`) saem do literal em `atraso-humano.ts` e passam a ser 4 colunas NULL em `channel_knobs` (a MESMA tabela dos knobs de pacing por conexão — 0010 e 0495; nada de tabela nova). NULL = default conservador em `defaults.ts` (900/22/1200/7500 — os valores de antes: regressão zero). O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha coluna: ele já é `throttle_ms` + `jitter_max_ms` (defaults 1200/800) e passa apenas a LER os knobs da conexão em vez do literal. CHECK de sanidade (`atraso_maximo_ms >= atraso_minimo_ms`, teto intervalMaxMs); exposição na ficha Anti-ban via `POST/GET /api/v1/ai/pacing`. Gate: `tests/unit/atraso-humano-knobs.test.ts`. | diff --git a/tests/unit/atraso-humano-knobs.test.ts b/tests/unit/atraso-humano-knobs.test.ts new file mode 100644 index 000000000..1b781fe29 --- /dev/null +++ b/tests/unit/atraso-humano-knobs.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + ATRASO_MAXIMO_MS, + ATRASO_MINIMO_MS, + ATRASO_NOTAR_MS, + MS_POR_CARACTERE, + atrasoHumanoEfetivo, + calcularAtrasoHumano, +} from "@/lib/agent-engine/agent/atraso-humano"; +import { PACING_DEFAULTS } from "@/lib/agent-engine/pacing/defaults"; +import { effectiveKnobs } from "@/lib/ai/pacing-knobs"; + +/** + * Issue #653 — o atraso humano antes da 1ª bolha vira knob por conexão. + * + * Os quatro números (`atraso_humano` NOTAR/POR_CARACTERE/MINIMO/MAXIMO) eram + * literais em `atraso-humano.ts`. Agora entram pelo `channel_knobs` (0499), com + * DEFAULT = os valores de antes: quem nunca configurou tem regressão ZERO, e o + * leitor (`loadChannelKnobs`/`effectiveKnobs`) preenche os defaults. + */ +describe("atraso humano: default idêntico ao código de antes (regressão zero)", () => { + it("os defaults do módulo espelham os valores históricos", () => { + expect(PACING_DEFAULTS.atrasoNotarMs).toBe(ATRASO_NOTAR_MS); // 900 + expect(PACING_DEFAULTS.msPorCaractere).toBe(MS_POR_CARACTERE); // 22 + expect(PACING_DEFAULTS.atrasoMinimoMs).toBe(ATRASO_MINIMO_MS); // 1200 + expect(PACING_DEFAULTS.atrasoMaximoMs).toBe(ATRASO_MAXIMO_MS); // 7500 + }); + + it("sem knobs, calcularAtrasoHumano devolve exatamente o histórico (piso e teto)", () => { + // O mesmo texto curto que o teste antigo prendia: cai no PISO. + expect(calcularAtrasoHumano("Oi, tudo bem?")).toBe(ATRASO_MINIMO_MS); + // Parágrafo longo cai no TETO. + expect(calcularAtrasoHumano("a".repeat(4000))).toBe(ATRASO_MAXIMO_MS); + // Vazio >= piso. + expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS); + }); + + it("atrasoHumanoEfetivo preenche todos os quatro com o default quando ausente", () => { + expect(atrasoHumanoEfetivo(undefined)).toEqual({ + atrasoNotarMs: ATRASO_NOTAR_MS, + msPorCaractere: MS_POR_CARACTERE, + atrasoMinimoMs: ATRASO_MINIMO_MS, + atrasoMaximoMs: ATRASO_MAXIMO_MS, + }); + // default parcial: o não-informado cai no default. + expect(atrasoHumanoEfetivo({ msPorCaractere: 10 })).toMatchObject({ msPorCaractere: 10 }); + expect(atrasoHumanoEfetivo({ msPorCaractere: 10 }).atrasoNotarMs).toBe(ATRASO_NOTAR_MS); + }); +}); + +describe("atraso humano: knobs por conexão mudam o resultado", () => { + it("NOTAR maior empurra o atraso para cima (mesmo texto)", () => { + const texto = "Temos sim."; + const base = calcularAtrasoHumano(texto); + const comNotar = calcularAtrasoHumano(texto, { atrasoNotarMs: base + 500, atrasoMaximoMs: 10_000 }); + expect(comNotar).toBeGreaterThan(base); + }); + + it("POR_CARACTERE maior pentencia em textos maiores", () => { + const curto = calcularAtrasoHumano("Oi", { msPorCaractere: 50, atrasoMaximoMs: 10_000 }); + const longo = calcularAtrasoHumano("a".repeat(40), { msPorCaractere: 50, atrasoMaximoMs: 10_000 }); + expect(longo).toBeGreaterThan(curto); + }); + + it("teto da conexão é respeitado (não o default 7500)", () => { + // Texto que no default explodiria no teto de 7500; com teto menor, para nele. + // bruto = 900 + 22*400 = 9700 (> 7500 nos dois casos). + expect(calcularAtrasoHumano("a".repeat(400), { atrasoMaximoMs: 3000 })).toBe(3000); + expect(calcularAtrasoHumano("a".repeat(400))).toBe(ATRASO_MAXIMO_MS); + }); + + it("piso da conexão pode ser menor que o piso padrão sem afrouxar anti-ban", () => { + // "" só ultrapassa o piso 1200 quando NOTAR > 1200; com NOTAR 50 o bruto + // (50) fica abaixo do piso 200 da conexão, que o leva para cima. + expect( + calcularAtrasoHumano("", { atrasoNotarMs: 50, atrasoMinimoMs: 200, atrasoMaximoMs: 5000 }), + ).toBe(200); + // Sem knobs, o "" recai no piso padrão 1200 (regressão zero). + expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS); + }); +}); + +describe("leitor efetivo: linha de channel_knobs sobre os defaults", () => { + it("sem linha, effectiveKnobs devolve os defaults", () => { + const eff = effectiveKnobs(null); + expect(eff.atrasoNotarMs).toBe(PACING_DEFAULTS.atrasoNotarMs); + expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere); + expect(eff.atrasoMinimoMs).toBe(PACING_DEFAULTS.atrasoMinimoMs); + expect(eff.atrasoMaximoMs).toBe(PACING_DEFAULTS.atrasoMaximoMs); + }); + + it("com linha, effectiveKnobs devolve o valor da conexão (e null volta ao default)", () => { + const eff = effectiveKnobs( + { + atraso_notar_ms: 1200, + ms_por_caractere: null, + atraso_minimo_ms: 1500, + atraso_maximo_ms: 6000, + } as never, + ); + expect(eff.atrasoNotarMs).toBe(1200); + expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere); // null -> default + expect(eff.atrasoMinimoMs).toBe(1500); + expect(eff.atrasoMaximoMs).toBe(6000); + }); +}); \ No newline at end of file diff --git a/tests/unit/atraso-humano-nao-afrouxa-o-anti-ban.test.ts b/tests/unit/atraso-humano-nao-afrouxa-o-anti-ban.test.ts index 2b2cb4a7d..1a6afcdaf 100644 --- a/tests/unit/atraso-humano-nao-afrouxa-o-anti-ban.test.ts +++ b/tests/unit/atraso-humano-nao-afrouxa-o-anti-ban.test.ts @@ -162,10 +162,15 @@ describe("fiação — a espera humana é paga UMA vez por turno", () => { expect(doSendMessage).toBeGreaterThan(-1); const i = FONTE_INBOUND.indexOf("send: (finalBody: string) =>", doSendMessage); expect(i).toBeGreaterThan(-1); - const janela = FONTE_INBOUND.slice(i, i + 1600); + const janela = FONTE_INBOUND.slice(i, i + 2400); // Os dois convivem: o jitter é throttle anti-ban entre mensagens físicas, o // atraso humano é a pausa do turno. Perder o primeiro é afrouxar o anti-ban. - expect(janela).toMatch(/jitter\s*[:=]\s*\(\)\s*=>\s*1200 \+ Math\.floor\(Math\.random\(\) \* 800\)/); + // Desde a 0499 o jitter LÊ os knobs da conexão (throttleMs + jitterMaxMs, + // defaults 1200/800) em vez do literal `1200 + rand*800` — mesma proteção, + // agora configurável por número. + expect(janela).toMatch(/const jitter = \(\) =>/); + expect(janela).toMatch(/pacingDoTurno\?\.knobs\.throttleMs \?\? 1200/); + expect(janela).toMatch(/Math\.floor\(Math\.random\(\) \* \(pacingDoTurno\?\.knobs\.jitterMaxMs \?\? 800\)\)/); expect(janela).toContain("sendInBubbles(texto, {"); // Issue #654: a pausa humana saiu daqui. Ela era paga no `antesDaPrimeira`, que // rodava dentro do callback `send` — isto é, com o `pg_advisory_xact_lock` do diff --git a/tests/unit/protecao-de-envio-abre-e-avisa.test.tsx b/tests/unit/protecao-de-envio-abre-e-avisa.test.tsx index 163c5b68e..fd36564e6 100644 --- a/tests/unit/protecao-de-envio-abre-e-avisa.test.tsx +++ b/tests/unit/protecao-de-envio-abre-e-avisa.test.tsx @@ -84,6 +84,10 @@ function canal(over: Partial = {}): ChannelSession { const KNOBS: PacingKnobs = { throttleMs: 1_200, jitterMaxMs: 800, + atrasoNotarMs: 900, + msPorCaractere: 22, + atrasoMinimoMs: 1_200, + atrasoMaximoMs: 7_500, windowStartHour: 7, windowEndHour: 22, respostaStartHour: 7, @@ -113,6 +117,8 @@ function itemDePacing(over: Partial = {}): PacingKnobsItem { defaults: KNOBS, bounds: { intervalMaxMs: 600_000, + msPorCaractereMax: 200, + atrasoMaximoMsMax: 60_000, hourLastStart: 23, hourEnd: 24, daily_limit: { min: 1, max: 2_000 },