mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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).
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
/**
|
||
* É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;
|
||
}
|