mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_*`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user