Files
DeskcommCRM/lib/ai/pacing-knobs.ts
T
webtecnica 7592f332c7 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).
2026-09-30 10:18:29 -03:00

307 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Épico Operação Visível (F2ii) — leitura/escrita dos knobs de pacing que o
* engine JÁ respeita (channel_knobs, coluna NULL = default conservador) + o teto
* diário absoluto (channel_sessions.daily_message_limit, fonte única — regra
* dura nº 3). Validação de entrada do operador usa KNOB_BOUNDS de
* lib/agent-engine/pacing/defaults.ts — números de pacing nunca nascem aqui.
*/
import { z } from "zod";
import {
KNOB_BOUNDS,
PACING_DEFAULTS,
type PacingKnobs,
} from "@/lib/agent-engine/pacing/defaults";
import { warmupCapFor } from "@/lib/agent-engine/pacing/engine";
import { fusoDaJanela, parseWarmupCaps } from "@/lib/agent-engine/pacing/store";
function isValidTimezone(tz: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Sanidade de UI para o teto diário (coluna do CRM, não knob do engine — o
* engine só LÊ channel_sessions.daily_message_limit). 0 é rejeitado: "desligar
* envios" tem forma expressa (pausar o agente), nunca um teto silencioso de 0.
*/
export const DAILY_LIMIT_BOUNDS = { min: 1, max: 10_000 } as const;
/**
* O fuso mais adiantado que existe é UTC+14 (Kiritimati). Logo, o maior DIA de
* calendário em vigor em algum lugar do planeta é o dia UTC de `agora + 14h`.
*/
const MAIOR_ADIANTAMENTO_MS = 14 * 3_600_000;
/** O dia do calendário (`yyyy-mm-dd`) de um instante, lido em UTC. */
function diaEmUtc(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
/**
* Hoje no calendário de quem está olhando a tela (`yyyy-mm-dd`) — o mesmo espaço
* de valores de um `<input type="date">`, que fala em dia LOCAL.
*
* `new Date().toISOString().slice(0, 10)` dá o dia UTC, e a oeste ele adianta:
* às 21h em São Paulo o `max` do campo "este número é usado desde" já oferecia
* AMANHÃ. Limite de campo é promessa: oferecer o que o servidor recusa é o
* controle decorativo ao contrário.
*/
export function diaDeHojeLocal(agora: Date = new Date()): string {
const mes = String(agora.getMonth() + 1).padStart(2, "0");
const dia = String(agora.getDate()).padStart(2, "0");
return `${agora.getFullYear()}-${mes}-${dia}`;
}
/**
* O DIA declarado em `number_activated_at` já começou em algum lugar do mundo?
*
* ## Por que a comparação é entre DIAS, e não entre um dia e um relógio
*
* O operador não escolhe um instante: ele escolhe um DIA num `<input
* type="date">`, e a tela encaixa esse dia às **12h UTC** (meia-noite viraria o
* dia anterior em qualquer fuso a oeste — ver `AntiBanSheet`). A guarda antiga
* comparava esse encaixe com `Date.now()`, isto é, um DIA contra um RELÓGIO: o
* encaixe de hoje só "chega" às 12:00 UTC, então declarar HOJE era recusado
* durante toda a manhã.
*
* Medido no SHA f700f3e1, varrendo as 24 horas com o relógio falso, em
* `America/Sao_Paulo` (UTC−3): a recusa começava às **03:00 UTC** (00:00 BRT — o
* instante em que a data local vira hoje) e só parava às **12:00 UTC** (09:00
* BRT). Nove horas de todo dia em que o campo oferecia hoje e o servidor
* respondia "a data não pode estar no futuro". E a recusa derrubava a ficha
* INTEIRA — o schema é `.strict()` e a rota devolve 422 antes de gravar
* qualquer campo —, então janela, throttle e teto diário iam junto: exatamente
* o desfecho que o PR #496 tinha acabado de consertar para o campo em branco.
*
* ## Por que o limite é UTC+14, e por que a folga não afrouxa proteção nenhuma
*
* Um dia sem fuso não tem instante: "3 de setembro" começa em UTC+14 e termina
* em UTC−12, 26 horas depois. Como o payload não carrega o fuso do navegador, a
* única fronteira honesta é a do planeta: recusar só o dia que não começou em
* lugar NENHUM. Fica de fora o absurdo (2030), que é o que a guarda existe para
* pegar.
*
* A folga de até um dia é segura porque data futura **não** adianta o
* aquecimento — ela o atrasa. Quem decide isso é o MOTOR, não esta guarda:
* `lib/agent-engine/pacing/engine.ts` faz `Math.max(0, …)` na idade, e o
* comentário dele já dizia por quê — "number_activated_at no futuro (typo do
* admin / clock skew) cai no degrau MAIS conservador — warm-up falha FECHADO".
* Idade 0 é o primeiro degrau dos `PACING_DEFAULTS`: 20 envios/dia. O mesmo
* está preso em `tests/unit/aquecimento-idade-do-numero.test.ts` ("data no
* futuro não vira idade negativa").
*
* O comentário anterior desta guarda afirmava o oposto ("ela ADIANTARIA o
* aquecimento") e era a justificativa de um rigor que só machucava quem estava
* certo — a razão escrita contradizia o motor que ela dizia proteger.
*/
export function diaDeclaradoJaComecou(iso: string, agora: Date = new Date()): boolean {
const instante = Date.parse(iso);
if (!Number.isFinite(instante)) return false;
return diaEmUtc(instante) <= diaEmUtc(agora.getTime() + MAIOR_ADIANTAMENTO_MS);
}
/** Campos editáveis pela tela — null = voltar ao default conservador do engine. */
export const pacingKnobsUpdateSchema = z
.object({
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(),
/**
* Janela da RESPOSTA do agente (0495). `0` e `24` são valores LEGÍTIMOS —
* é assim que o dono declara "responde 24h" — então o schema é o mesmo do
* par de disparo, e quem valida start<end é `windowIsValid` sobre o par
* RESULTANTE, depois de mesclar com o que já está gravado.
*/
resposta_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(),
resposta_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(),
allow_sunday: z.boolean().nullable().optional(),
timezone: z
.string()
.refine(isValidTimezone, "timezone IANA inválida (ex.: America/Sao_Paulo)")
.nullable()
.optional(),
daily_message_limit: z
.number()
.int()
.min(DAILY_LIMIT_BOUNDS.min)
.max(DAILY_LIMIT_BOUNDS.max)
.optional(),
/**
* Desde quando o NÚMERO é usado — não desde quando esta conexão existe.
*
* O warm-up mede idade, e a idade nascia do instante em que o número era
* pareado aqui: reconectar um número usado há meses o rebaixava a
* recém-nascido (teto de 20 envios/dia) sem nenhum caminho para corrigir.
*
* A guarda é de SANIDADE do dia declarado, não de proteção: só recusa o dia
* que ainda não começou em lugar nenhum do mundo. Ver
* `diaDeclaradoJaComecou` — inclusive por que comparar com `Date.now()`
* calava o campo durante a manhã inteira de quem está a oeste.
*/
number_activated_at: z
.string()
.datetime({ offset: true })
.refine(
(iso) => diaDeclaradoJaComecou(iso),
"a data é de um dia que ainda não começou em lugar nenhum do mundo",
)
.nullable()
.optional(),
/**
* "Este número já está aquecido" — pula os degraus por completo, gravando um
* único degrau sem teto. Existe ao lado da data porque são perguntas
* diferentes: a data é um FATO que o dono conhece; isto é uma DECISÃO dele,
* assumindo o risco de banimento de um número que talvez não esteja pronto.
*/
skip_warmup: z.boolean().optional(),
})
.strict();
export type PacingKnobsUpdate = z.infer<typeof pacingKnobsUpdateSchema>;
export interface ChannelKnobsRow {
throttle_ms: number | null;
jitter_max_ms: number | null;
window_start_hour: number | null;
window_end_hour: number | null;
/** 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;
/** idade do número p/ warm-up (linha ausente = engine trata como idade 0). */
number_activated_at?: string | null;
}
/**
* Janela efetiva coerente: [start, end) com start < end — a mesma leitura que o
* engine faz. Valida o PAR RESULTANTE (row nova mesclada com a atual/default),
* não só os campos enviados: PATCH parcial não pode criar janela invertida.
*/
export function windowIsValid(startHour: number, endHour: number): boolean {
return startHour < endHour;
}
/**
* Knobs efetivos para exibição: linha (se houver) sobre os defaults do engine.
* `fusoDaOrg` entra para a tela mostrar o fuso em que o motor de fato avalia a
* janela (`fusoDaJanela`) — sem ele, "Usar o padrão" anunciava São Paulo a
* uma empresa de Lisboa.
*/
export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string | null): PacingKnobs {
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.
// A tela mostra `null` como "Usar o padrão" — e o padrão É o par de disparo,
// então mostrar 7h-22h aqui não mente: é o que o motor vai aplicar.
respostaStartHour: row?.resposta_start_hour ?? row?.window_start_hour ?? PACING_DEFAULTS.respostaStartHour,
respostaEndHour: row?.resposta_end_hour ?? row?.window_end_hour ?? PACING_DEFAULTS.respostaEndHour,
allowSunday: row?.allow_sunday ?? PACING_DEFAULTS.allowSunday,
timezone: fusoDaJanela(row?.timezone, fusoDaOrg),
warmupDailyCaps: parseWarmupCaps(row?.warmup_daily_caps) ?? PACING_DEFAULTS.warmupDailyCaps,
};
}
/** Forma gravada em `warmup_daily_caps` quando o dono declara o número já aquecido. */
export const WARMUP_PULADO = [{ minAgeDays: 0, cap: null }] as const;
/** A configuração de warm-up desta conexão é a de "já aquecido"? */
export function warmupEstaPulado(row: ChannelKnobsRow | null): boolean {
const caps = parseWarmupCaps(row?.warmup_daily_caps);
return caps?.length === 1 && caps[0]?.minAgeDays === 0 && caps[0]?.cap === null;
}
/** Dias completos desde a ativação do número. Sem data = 0 (o motor é conservador). */
export function idadeEmDias(row: ChannelKnobsRow | null, agora: Date = new Date()): number {
const iso = row?.number_activated_at;
if (!iso) return 0;
const ms = agora.getTime() - new Date(iso).getTime();
return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : 0;
}
/**
* Payload de GET para a tela: efetivo + o que é override + limites de edição.
*
* `warmup` vai junto porque a tela precisava explicar um veto que ela não sabia
* calcular: o operador lia "número em aquecimento, limite atingido", subia o teto
* diário — o único campo que a tela oferecia — e nada mudava, porque quem barrava
* era o teto por IDADE. Agora o número que decide aparece ao lado do que o
* decide.
*/
export function knobsView(
row: ChannelKnobsRow | null,
agora: Date = new Date(),
fusoDaOrg?: string | null,
) {
const efetivo = effectiveKnobs(row, fusoDaOrg);
const dias = idadeEmDias(row, agora);
return {
effective: efetivo,
overrides: row,
defaults: PACING_DEFAULTS,
bounds: { ...KNOB_BOUNDS, daily_limit: DAILY_LIMIT_BOUNDS },
warmup: {
number_activated_at: row?.number_activated_at ?? null,
age_days: dias,
skipped: warmupEstaPulado(row),
/** Teto de HOJE pelo aquecimento. null = sem teto (formado ou pulado). */
cap_today: warmupCapFor(dias, efetivo.warmupDailyCaps),
},
};
}
/**
* O valor a GRAVAR para um knob booleano da ficha Anti-ban: `null` quando o
* operador está no default (herda), o booleano quando ele diverge (override).
*
* ## Por que isto existe
*
* Todo knob de TEXTO da ficha já sabia dizer "não mexi": campo vazio vira `null`
* e o motor herda o default (`intOrNull`, `msOrNull`, o `trim()` do timezone).
* O Switch de "Enviar aos domingos" não sabia — ele enviava **sempre** o
* booleano que estava na tela, e o que estava na tela, sem override, era o
* default do dia.
*
* Resultado medido em produção: em 2026-08-06 o default era `false`, e um save
* daquela ficha — feito para declarar o aquecimento, não para mexer em domingo —
* gravou `allow_sunday=false` como override permanente. Quando o produto mudou o
* default para `true` em 2026-08-20, essa instalação ficou para trás com uma
* escolha que ninguém fez, e o número passou a ficar mudo todo domingo.
*
* ## A regra, e o que ela preserva
*
* Igual ao default ⇒ `null`. Diferente ⇒ o valor.
*
* Isso NÃO apaga escolha de ninguém: quem desligou o domingo quando o default
* era ligado continua com `false` gravado, porque diverge. O que some é só o
* override que nunca foi uma decisão — e com ele some o congelamento.
*/
export function valorDeOverride(valorNaTela: boolean, padrao: boolean): boolean | null {
return valorNaTela === padrao ? null : valorNaTela;
}