feat(agent): atraso humano configurável por conexão (0499)

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).
This commit is contained in:
webtecnica
2026-09-30 10:18:29 -03:00
parent 1c8aa57222
commit 7592f332c7
17 changed files with 436 additions and 10 deletions
+5 -1
View File
@@ -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<Response> {
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,
+84
View File
@@ -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) {
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Atraso humano antes da primeira resposta (ms)")}</Label>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Ver a notificação")}</span>
<Input
type="number"
min={0}
max={item.bounds.intervalMaxMs}
inputMode="numeric"
placeholder={String(eff.atrasoNotarMs)}
value={form.atraso_notar_ms}
onChange={(e) => set({ atraso_notar_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Tempo para ver a notificação em ms")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Por caractere")}</span>
<Input
type="number"
min={0}
max={item.bounds.msPorCaractereMax}
inputMode="numeric"
placeholder={String(eff.msPorCaractere)}
value={form.ms_por_caractere}
onChange={(e) => set({ ms_por_caractere: e.target.value })}
disabled={!canWrite}
aria-label={t("Milissegundos por caractere da resposta")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Mínimo")}</span>
<Input
type="number"
min={0}
max={item.bounds.intervalMaxMs}
inputMode="numeric"
placeholder={String(eff.atrasoMinimoMs)}
value={form.atraso_minimo_ms}
onChange={(e) => set({ atraso_minimo_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Atraso humano mínimo em ms")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Máximo")}</span>
<Input
type="number"
min={0}
max={item.bounds.atrasoMaximoMsMax}
inputMode="numeric"
placeholder={String(eff.atrasoMaximoMs)}
value={form.atraso_maximo_ms}
onChange={(e) => set({ atraso_maximo_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Atraso humano máximo em ms")}
className="w-24"
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
{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.",
)}
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Teto diário de envios")}</Label>
<Input
+2
View File
@@ -30,6 +30,8 @@ export interface PacingKnobsItem {
defaults: PacingKnobs;
bounds: {
intervalMaxMs: number;
msPorCaractereMax: number;
atrasoMaximoMsMax: number;
hourLastStart: number;
hourEnd: number;
daily_limit: { min: number; max: number };
+34 -6
View File
@@ -49,7 +49,10 @@
*/
import type { Logger } from '../obs/logger';
/** Ver o cabeçalho: a parcela que não depende do tamanho do texto. */
/**
* Ver o cabeçalho: a parcela que não depende do tamanho do texto.
* É o DEFAULT — quem configura por conexão (channel_knobs 0499) recebe outro.
*/
export const ATRASO_NOTAR_MS = 900;
/** ≈45 caracteres/s — rápido de propósito; ver o cabeçalho. */
@@ -61,14 +64,34 @@ export const ATRASO_MINIMO_MS = 1200;
/** Acima disto o silêncio lê como queda, não como digitação. */
export const ATRASO_MAXIMO_MS = 7500;
/** Os quatro números do atraso humano — o default é o do código de antes. */
export interface AtrasoHumanoKnobs {
atrasoNotarMs?: number;
msPorCaractere?: number;
atrasoMinimoMs?: number;
atrasoMaximoMs?: number;
}
/** Os quatro números com o default do módulo preenchido (NULL/ausente = valor de antes). */
export function atrasoHumanoEfetivo(knobs: AtrasoHumanoKnobs | undefined): Required<AtrasoHumanoKnobs> {
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<void>;
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<number>
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.
+23 -1
View File
@@ -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<void>((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,
+24
View File
@@ -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_*`
+10
View File
@@ -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<ChannelKnobsRow>(
`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
+14
View File
@@ -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.
+6
View File
@@ -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
+12
View File
@@ -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: [
{
+12
View File
@@ -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" },
+37
View File
@@ -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/
@@ -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)
);
+1
View File
@@ -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`. |
+107
View File
@@ -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);
});
});
@@ -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
@@ -84,6 +84,10 @@ function canal(over: Partial<ChannelSession> = {}): 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> = {}): PacingKnobsItem {
defaults: KNOBS,
bounds: {
intervalMaxMs: 600_000,
msPorCaractereMax: 200,
atrasoMaximoMsMax: 60_000,
hourLastStart: 23,
hourEnd: 24,
daily_limit: { min: 1, max: 2_000 },