diff --git a/.changes/janela-de-resposta-24h.md b/.changes/janela-de-resposta-24h.md deleted file mode 100644 index 51a408714..000000000 --- a/.changes/janela-de-resposta-24h.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -impacto: capacidade_nova -secao: adicionado -titulo: O agente responde a qualquer hora, sem abrir o horário do disparo ---- - -A janela anti-ban era **uma** só, e ela atendia a três coisas: a resposta do -agente, o disparo em massa e a cutucar de conversa parada. Com um único par de -horas, abrir o atendimento para 24 horas abria também o disparo — o que ninguém -pediu e é o caminho mais curto para o número ser banido. - -Agora são **duas janelas**, por número: - -| | antes | agora | -|---|---|---| -| Resposta a quem escreveu | 7h–22h | **0h–24h** | -| Disparos em massa | 7h–22h | 7h–22h (inalterado) | -| Cutucar conversa parada | 7h–22h | 7h–22h (inalterado) | - -O que separa as duas é o tipo do envio: uma reação a uma mensagem recebida lê a -janela de resposta; a cutucar e o disparo leem a janela comercial. - -**O anti-ban continua inteiro.** Abrir o horário não abre o limite: cap diário, -degraus de warm-up por idade do número e o intervalo entre envios seguem valendo -para os dois lados. Um número novo continua com 20 mensagens por dia até completar -o warm-up. - -### ⚠️ Requer atenção - -Atualize a instalação (`update.sh`) para receber as duas colunas novas. A -instalação que não atualizar **continua com o comportamento de sempre** — a -janela de resposta espelha a de disparo até alguém gravar o valor novo. Não há -o que configurar para o sistema não quebrar. - -Para ajustar o horário de cada tipo de envio, grave na tabela `channel_knobs` do -seu número: - -```sql --- resposta 24h (padrão desta instalação) -update channel_knobs set reengajar_start_hour = 0, reengajar_end_hour = 24 - where organization_id = '' and channel_session_id = ''; - --- cutucar e disparo em 9h–21h, mais apertado que o padrão -update channel_knobs set window_start_hour = 9, window_end_hour = 21 - where organization_id = '' and channel_session_id = ''; -``` - -Coluna pela metade não abre nada: sem o par completo, vale a janela de disparo. -É de propósito — `reengajar_start_hour = 0` sozinho produziria `0h–22h`, que é -abrir a madrugada pelo caminho que parece conservador. diff --git a/app/api/v1/ai/pacing/route.ts b/app/api/v1/ai/pacing/route.ts index bb7b76a9e..dfedff39f 100644 --- a/app/api/v1/ai/pacing/route.ts +++ b/app/api/v1/ai/pacing/route.ts @@ -29,7 +29,9 @@ import { export const dynamic = "force-dynamic"; const KNOB_COLUMNS = - // As duas janelas entram no SELECT: sem `reengajar_*` aqui, a ficha Anti-ban\n // mostraria 7h-22h como se fosse a janela da resposta — e é a de DISPARO.\n "throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, reengajar_start_hour, reengajar_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at"; + // 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"; /** * `organizations.timezone`, para a tela mostrar o fuso em que o motor avalia a @@ -163,8 +165,8 @@ export async function PUT(req: NextRequest): Promise { jitter_max_ms: null, window_start_hour: null, window_end_hour: null, - reengajar_start_hour: null, - reengajar_end_hour: null, + resposta_start_hour: null, + resposta_end_hour: null, allow_sunday: null, timezone: null, warmup_daily_caps: null, @@ -181,20 +183,17 @@ export async function PUT(req: NextRequest): Promise { ); } // A janela da RESPOSTA é validada pelo mesmo par-resultante (0495). Sem isto, - // a tela aceitaria `reengajar_start_hour=22, end=7`, que o motor traduz em + // a tela aceitaria `resposta_start_hour=22, end=7`, que o motor traduz em // "nunca responde" — e o operador só descobriria quando o cliente parasse de // receber resposta, que é o sintoma que ele não consegue ligar para a tela. - // - // `0..24` continua válido: é assim que o dono declara "responde 24h". - if (eff.reengajarStartHour !== 0 || eff.reengajarEndHour !== 24) { - if (!windowIsValid(eff.reengajarStartHour, eff.reengajarEndHour)) { - return fail( - "validation_failed", - `Janela de resposta inválida: início (${eff.reengajarStartHour}h) precisa ser antes do fim (${eff.reengajarEndHour}h). Use 0 e 24 para responder a qualquer hora.`, - 422, - { requestId }, - ); - } + // `0..24` passa por `windowIsValid` (0 < 24): é assim que se declara "responde 24h". + if (!windowIsValid(eff.respostaStartHour, eff.respostaEndHour)) { + return fail( + "validation_failed", + `Janela de resposta inválida: início (${eff.respostaStartHour}h) precisa ser antes do fim (${eff.respostaEndHour}h). Use 0 e 24 para responder a qualquer hora.`, + 422, + { requestId }, + ); } if (Object.keys(knobFields).length > 0) { diff --git a/lib/agent-engine/agent/inbound-turn.ts b/lib/agent-engine/agent/inbound-turn.ts index 4d275e5f3..ee2572726 100644 --- a/lib/agent-engine/agent/inbound-turn.ts +++ b/lib/agent-engine/agent/inbound-turn.ts @@ -1793,6 +1793,20 @@ export async function runAgentTurn( * planejamento: não abrem o WhatsApp de ninguém. * * `operator_turn` — retaguarda (mexe no funil), nunca fala com o lead. */ +/** + * ═══ RESPOSTA vs RETOMADA — a distinção que a janela da 0495 faz ═══ + * + * `turnoVaiFalarComOLead` admite `followup_turn`, que RETOMA conversa parada — e + * retomar NÃO é responder: abrir `followup_turn` junto faria o número mandar + * "e aí, tudo certo?" às 4h para quem dormiu. + * + * Só a REAÇÃO a uma mensagem recebida lê a janela de resposta. `case_reply_turn` + * entra porque responde a um caso em aberto: ninguém "chama" um caso, o caso chama. + */ +export function eTurnoDeResposta(job: Pick): boolean { + return job.kind === 'inbound_turn' || job.kind === 'case_reply_turn'; +} + function turnoVaiFalarComOLead(job: JobRow): boolean { if (job.kind === 'inbound_turn' || job.kind === 'case_reply_turn') return true; if (job.kind !== 'followup_turn') return false; @@ -1930,26 +1944,12 @@ async function executarTurnoDoAgente( // Só a JANELA adia. Cap diário e warm-up continuam com o gate de envio: eles // dependem de quanto já saiu hoje, e antecipá-los aqui adiaria turno que, na // hora do envio, teria passado. - // ═══ RESPOSTA vs CUTUCAR — a distinção que a janela de 0381 faz ═══ - // - // `turnoVaiFalarComOLead` (guarda acima) admite `followup_turn`, que é a CUTUCAR - // de conversa parada — e cutucar NÃO é responder. O dono foi explícito: - // responder 24h, nunca disparar nem puxar conversa. Abrir `followup_turn` junto - // faria o número mandar "e aí, tudo certo?" às 4h para quem dormiu. - // - // Só a REAÇÃO a uma mensagem recebida vale `resposta: true`. `case_reply_turn` - // entra porque é resposta a um caso em aberto (o atendente/documento), não - // cutucar: ninguém "chama" um caso, o caso chama. - const eResposta = (j: JobRow): boolean => - j.kind === 'inbound_turn' || j.kind === 'case_reply_turn'; - if (!preview && turnoVaiFalarComOLead(liveJob())) { const { knobs } = await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog); const agora = clock(); - const resposta = eResposta(liveJob()); - // `resposta` separa as janelas: reação a quem escreveu lê `reengajar*` - // (0381, por padrão 0h-24h = o dono pediu); cutucar e disparo em massa leem - // `window*` (7h-22h, inalterado). É este o ponto único onde as duas saem. + const resposta = eTurnoDeResposta(liveJob()); + // `resposta` separa as janelas: reação a quem escreveu lê `resposta*` (0495, + // que herda `window*` quando vazia); retomada e disparo leem `window*`. if (!janelaDeEnvioAberta(agora, knobs, resposta)) { const abertura = proximaAberturaDaJanela(agora, knobs, resposta); await rescheduleJob(pool, liveJob().id, ctx.workerId, { @@ -1958,8 +1958,8 @@ async function executarTurnoDoAgente( reason: 'fora da janela anti-ban de envio — turno adiado para a abertura', }); runLog.info('turno adiado — fora da janela anti-ban de envio', { - janela: `${resposta ? knobs.reengajarStartHour : knobs.windowStartHour}h-${resposta ? knobs.reengajarEndHour : knobs.windowEndHour}h`, - tipo: resposta ? 'resposta' : 'cutucar', + janela: `${resposta ? knobs.respostaStartHour : knobs.windowStartHour}h-${resposta ? knobs.respostaEndHour : knobs.windowEndHour}h`, + tipo: resposta ? 'resposta' : 'retomada', timezone: knobs.timezone, abertura: abertura.toISOString(), }); @@ -1976,7 +1976,7 @@ async function executarTurnoDoAgente( tenantId, channelSessionId: input.channelSessionId, abertura, - janela: `${resposta ? knobs.reengajarStartHour : knobs.windowStartHour}h-${resposta ? knobs.reengajarEndHour : knobs.windowEndHour}h`, + janela: `${resposta ? knobs.respostaStartHour : knobs.windowStartHour}h-${resposta ? knobs.respostaEndHour : knobs.windowEndHour}h`, timezone: knobs.timezone, domingoDesligado: !knobs.allowSunday, }); @@ -2876,9 +2876,8 @@ async function executarTurnoDoAgente( // Só ESTE gate muda; stop, LGPD e pacing continuam valendo integralmente. isTemplate: true, optedOutThisTurn, - // Resposta do turno, mesmo sendo template: quem escreveu espera volta - // a qualquer hora (0381). Só o disparo em massa usa a janela comercial. - resposta: eResposta(liveJob()), + // Resposta do turno, mesmo sendo template: lê a janela de resposta (0495). + resposta: eTurnoDeResposta(liveJob()), crmDailyLimit: null, now: clock(), sleep: deps.sleep, @@ -3094,10 +3093,9 @@ async function executarTurnoDoAgente( body, optedOutThisTurn, // `inbound_turn`/`case_reply_turn` respondem a quem escreveu e leem a - // janela de RESPOSTA (0381, 0h-24h nesta instalação). `followup_turn` - // é cutucar de conversa parada e continua na janela de DISPARO: o dono - // pediu responder 24h, nunca puxar conversa. - resposta: eResposta(liveJob()), + // janela de RESPOSTA (0495). `followup_turn` retoma conversa parada e + // continua na janela de DISPARO. + resposta: eTurnoDeResposta(liveJob()), // ponytail: channel_sessions.daily_message_limit do CRM ainda não é lido // no runtime — null cai nos degraus de warm-up (conservadores). Injetar // aqui quando o drain expuser o limite da sessão. @@ -4284,6 +4282,9 @@ async function executarTurnoDoAgente( channelSessionId: input.channelSessionId, body: texto, optedOutThisTurn, + // Sai no MESMO turno da resposta: sem isto, às 3h com a janela de + // resposta aberta, o agente responde e a pergunta do roteiro é vetada. + resposta: eTurnoDeResposta(liveJob()), crmDailyLimit: null, // A pergunta repete por design (foi feita e não respondida); o // anti-blast vetaria justamente o que esta trava garante. Mesmo diff --git a/lib/agent-engine/guardrails/before-send.ts b/lib/agent-engine/guardrails/before-send.ts index 54ba7419a..466626720 100644 --- a/lib/agent-engine/guardrails/before-send.ts +++ b/lib/agent-engine/guardrails/before-send.ts @@ -126,17 +126,17 @@ export interface GateContext { crmDailyLimit: number | null; rng?: () => number; /** - * Este envio é RESPOSTA a uma mensagem recebida, ou disparo/cutucar? + * Este envio é RESPOSTA a uma mensagem recebida, ou disparo/retomada? * * ⚠️ OMITIDO = disparo (janela `window*`, 7h-22h). É o default que mantém - * todo chamador que não conhece a 0381 no comportamento antigo, e é a + * todo chamador que não conhece a 0495 no comportamento antigo, e é a * direção segura: quem esquece o campo continua preso ao horário comercial * em vez de abrir o número às 3h. * * O `inbound_turn` (cliente escreveu) e o `case_reply_turn` passam `true` e leem - * `reengajar*`. O disparo em massa NÃO passa por este gate — ele usa + * `resposta*`. O disparo em massa NÃO passa por este gate — ele usa * `decidePacing` direto (`lib/prospecting/worker.ts`) — então o valor aqui - * só distingue resposta de cutucar de follow-up. + * só distingue resposta de retomada por follow-up. */ resposta?: boolean; }; @@ -910,9 +910,9 @@ export interface RunBeforeSendArgs { */ crmDailyLimit: number | null; /** - * Este envio é RESPOSTA a uma mensagem recebida (janela `reengajar*`, 0381) ou - * disparo/cutucar (janela `window*`)? OMITIDO = disparo — o default que deixa - * todo chamador anterior à 0381 no comportamento antigo. + * Este envio é RESPOSTA a uma mensagem recebida (janela `resposta*`, 0495) ou + * disparo/retomada (janela `window*`)? OMITIDO = disparo — o default que deixa + * todo chamador anterior à 0495 no comportamento antigo. */ resposta?: boolean; now: Date; diff --git a/lib/agent-engine/pacing/defaults.ts b/lib/agent-engine/pacing/defaults.ts index 41a1b899a..d7a0a2c5e 100644 --- a/lib/agent-engine/pacing/defaults.ts +++ b/lib/agent-engine/pacing/defaults.ts @@ -23,7 +23,7 @@ export interface PacingKnobs { jitterMaxMs: 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 cutucar de conversa + * disparo em massa (`lib/prospecting/worker.ts`) e para a retomada de conversa * parada (`lib/automation/janela-do-canal.ts`). */ windowStartHour: number; @@ -38,18 +38,18 @@ export interface PacingKnobs { * que o dono comprou. Com um knob só, abrir o atendimento para 24h abria * junto o disparo — e o dono pediu exatamente para que NÃO abrisse. * - * Por isso o `PacingInput` do gate tem `reengajar_*` em vez de mexer na janela + * Por isso o `PacingInput` do gate tem `resposta_*` em vez de mexer na janela * global: `insideWindow` do disparo continua lendo `window*`, e a RESPOSTA lê * estes dois. Um canal que nunca gravou as colunas novas (`null` no banco) - * recebe `PACING_DEFAULTS.reengajar*` — que espelham `window*` —, então nenhum + * recebe `PACING_DEFAULTS.resposta*` — que espelham `window*` —, então nenhum * clone muda de comportamento por omissão. * * ⚠️ `allowSunday` NÃO tem par aqui de propósito: domingo liberado é o default * desde a 0010 e vale para as duas janelas. Se um dia domingo virar knob * separado, ele pertence aqui, não em `PacingInput`. */ - reengajarStartHour: number; - reengajarEndHour: number; + respostaStartHour: number; + respostaEndHour: number; /** * Enviar aos domingos. **Ligado por default** — a janela horária cala à noite, * e o domingo inteiro mudo era cortesia demais: num CRM de atendimento, quem @@ -85,12 +85,12 @@ export const PACING_DEFAULTS: PacingKnobs = { jitterMaxMs: 800, windowStartHour: 7, // janela 7h-22h windowEndHour: 22, - // Espelha a janela de disparo: quem nunca gravou as colunas `reengajar_*` + // Espelha a janela de disparo: quem nunca gravou as colunas `resposta_*` // continua com o comportamento de sempre (a resposta espera fora da janela). // O dono que QUER 24h grava 0 e 24 no `channel_knobs` — não neste arquivo, // que é default de fallback, não configuração de instalação. - reengajarStartHour: 7, - reengajarEndHour: 22, + respostaStartHour: 7, + respostaEndHour: 22, allowSunday: true, timezone: 'America/Sao_Paulo', // Número sem linha em channel_knobs é tratado como idade 0 (o degrau mais diff --git a/lib/agent-engine/pacing/engine.ts b/lib/agent-engine/pacing/engine.ts index 8229f928a..7fdfc45e1 100644 --- a/lib/agent-engine/pacing/engine.ts +++ b/lib/agent-engine/pacing/engine.ts @@ -43,15 +43,15 @@ export interface PacingInput { banRisk?: boolean; /** * Esta decisão é para a RESPOSTA do agente (o cliente escreveu e espera - * resposta) ou para o DISPARO (envio em massa / cutucar)? + * resposta) ou para o DISPARO (envio em massa / retomada de conversa parada)? * - * ⚠️ `true` (resposta) lê `reengajar*`; `false`/omitido (disparo) lê + * ⚠️ `true` (resposta) lê `resposta*`; `false`/omitido (disparo) lê * `window*`. Default `false` porque TODO chamador existente é disparo ou * não-POSTO — o `pacingGate` precisa declarar a resposta explicitamente para * a separação valer, e é o que a torna visível numa revisão de código. * * Responder e disparar são riscos diferentes: 50 mensagens de madrugada - * baninam o número, uma resposta para quem escreveu às 3h é o serviço. + * levam o número ao banimento; uma resposta para quem escreveu às 3h é o serviço. */ resposta?: boolean; /** [0,1) — injetável nos testes; default Math.random. */ @@ -72,9 +72,8 @@ export function decidePacing(input: PacingInput): PacingDecision { const banRisk = input.banRisk ?? true; // default preserva o comportamento atual const resposta = input.resposta ?? false; // default = disparo (janela restritiva) const wall = wallClock(now, knobs.timezone); - // Resposta lê `reengajar*`, disparo lê `window*`. Os knobs comemam sem - // `reengajar*` válido (clone sem a 0381 aplicado) caem no par de disparo — - // é o comportamento de sempre, não um terceiro valor inventado. + // Resposta lê `resposta*`, disparo lê `window*`. Coluna vazia já chega aqui + // preenchida com a de disparo (`loadChannelKnobs`, 0495). const janela = janelaDoPacing(knobs, resposta); if (!insideWindow(wall, knobs, janela)) { @@ -216,9 +215,9 @@ export function dayStartInTz(instant: Date, timezone: string): Date { * saída (ver `inbound-turn.ts`). O gate de envio continua sendo o que decide de * verdade: isto é só o atalho barato, sem tocar em caps nem em throttle. * - * `resposta` separa as janelas: o turno inbound é RESPOSTA (lê `reengajar*`) e - * o disparo/cutucar é `false` (lê `window*`). Omitir = disparo, que é o - * comportamento de todo chamador anterior a 0381. + * `resposta` separa as janelas: o turno inbound é RESPOSTA (lê `resposta*`) e + * o disparo/retomada é `false` (lê `window*`). Omitir = disparo, que é o + * comportamento de todo chamador anterior a 0495. */ export function janelaDeEnvioAberta( now: Date, @@ -247,21 +246,7 @@ function janelaDoPacing( resposta: boolean, ): { start: number; end: number } { if (!resposta) return { start: knobs.windowStartHour, end: knobs.windowEndHour }; - // Par de resposta ausente ou incompleto (clone sem a 0381, ou coluna gravada só - // pela metade): cai na janela de disparo INTEIRA, nunca metade dela. - // - // ⚠️ Misturar as duas colunas (`reengajarStartHour` de uma, `windowEndHour` de - // outra) produziria uma janela que ninguém configurou: com só o início gravado - // como 0, sairia `0h-22h` — que é abrir a madrugada sem ninguém ter pedido, - // pelo caminho que parece mais conservador. Ou `7h-24h`, que abre a noite. - // Qualquer par pela metade é configuração inválida e vale o par completo de - // disparo, que é o comportamento de sempre. - const temInicio = knobs.reengajarStartHour !== undefined && knobs.reengajarStartHour !== null; - const temFim = knobs.reengajarEndHour !== undefined && knobs.reengajarEndHour !== null; - if (!temInicio || !temFim) { - return { start: knobs.windowStartHour, end: knobs.windowEndHour }; - } - return { start: knobs.reengajarStartHour, end: knobs.reengajarEndHour }; + return { start: knobs.respostaStartHour, end: knobs.respostaEndHour }; } function insideWindow( diff --git a/lib/agent-engine/pacing/janela-de-resposta.test.ts b/lib/agent-engine/pacing/janela-de-resposta.test.ts index b9ab48597..fad014e6f 100644 --- a/lib/agent-engine/pacing/janela-de-resposta.test.ts +++ b/lib/agent-engine/pacing/janela-de-resposta.test.ts @@ -1,16 +1,17 @@ /** - * A janela de RESPOSTA é diferente da janela de DISPARO (migration 0381). + * A janela de RESPOSTA é diferente da janela de DISPARO (migration 0495). * - * O dono pediu: responder a qualquer hora, NUNCA disparar nem cortar conversa - * parada fora do horário comercial. Antes da 0381 as duas coisas liam o mesmo - * par de horas de disparo, então abrir o atendimento para 24h abria o disparo - * junto — exatamente o que não foi pedido. + * Responder a quem escreveu pode sair a qualquer hora; disparo, prospecção e + * retomada de conversa parada, não. Antes da 0495 as duas coisas liam o mesmo + * par de horas de disparo, então abrir o atendimento para 24h abria o disparo junto. * * Estes testes existem para travar a SEPARAÇÃO, não o padrão: se alguém voltar * a ler as horas de DISPARO no caminho da resposta, este arquivo reprova. */ import { describe, expect, it } from 'vitest'; +import { effectiveKnobs, type ChannelKnobsRow } from '@/lib/ai/pacing-knobs'; + import { PACING_DEFAULTS, type PacingKnobs } from './defaults'; import { decidePacing, @@ -42,16 +43,21 @@ const knobs = (over: Partial = {}): PacingKnobs => ({ const estado = { lastSentAt: null, sentToday: 0, numberActivatedAt: null }; -describe('janela de resposta separada da janela de disparo (0381)', () => { +const LINHA_VAZIA: ChannelKnobsRow = { + throttle_ms: null, jitter_max_ms: null, window_start_hour: null, window_end_hour: null, + allow_sunday: null, timezone: null, warmup_daily_caps: null, +}; + +describe('janela de resposta separada da janela de disparo (0495)', () => { it('a 3h a resposta é liberada e o disparo é barrado', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(true); expect(janelaDeEnvioAberta(AS_3H, k, false)).toBe(false); }); it('o mesmo número de knobs decide diferente conforme o tipo de envio', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); const resposta = decidePacing({ now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: true, @@ -66,37 +72,44 @@ describe('janela de resposta separada da janela de disparo (0381)', () => { }); it('omitir `resposta` é DISPARO — a direção que fecha o número', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); // O chamador que esquece o campo não pode abrir o número às 3h. expect(janelaDeEnvioAberta(AS_3H, k)).toBe(false); expect(decidePacing({ now: AS_3H, knobs: k, state: estado, crmDailyLimit: null }).allow).toBe(false); }); it('dentro do horário comercial os dois são liberados', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); expect(janelaDeEnvioAberta(AS_10H, k, true)).toBe(true); expect(janelaDeEnvioAberta(AS_10H, k, false)).toBe(true); }); - it('sem `reengajar*` gravado, a resposta herda a janela do disparo', () => { - // Clone que rodou a 0381 sem gravar as colunas: `undefined` cai no par de - // disparo, que é o comportamento de sempre — não vira 0-24 sozinho. - const k = knobs({ reengajarStartHour: undefined as unknown as number, reengajarEndHour: undefined as unknown as number }); + it('coluna vazia no banco: a resposta herda a janela de disparo', () => { + // O caminho real: `null` em `resposta_*` vira o par `window_*` na leitura + // (`effectiveKnobs`, mesma regra de `loadChannelKnobs`). Não vira 0-24 sozinho. + const k = effectiveKnobs({ + ...LINHA_VAZIA, window_start_hour: 9, window_end_hour: 18, + resposta_start_hour: null, resposta_end_hour: null, + }); + expect([k.respostaStartHour, k.respostaEndHour]).toEqual([9, 18]); expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(false); expect(janelaDeEnvioAberta(AS_10H, k, true)).toBe(true); }); - it('par de resposta incompleto (só o início) não abre a janela', () => { - // Gravar só `reengajar_start_hour=0` e deixar o fim vazio é configuração - // pela metade. A resposta NÃO pode virar 0h-infinito por acidente. - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: undefined as unknown as number }); - expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(false); + it('o fallback é coluna a coluna, igual ao que a tela mostra no placeholder', () => { + // Só o início gravado: o fim segue o do disparo. É o que a ficha Anti-ban + // exibe no campo vazio, então o operador vê a janela que o motor aplica. + const k = effectiveKnobs({ + ...LINHA_VAZIA, window_start_hour: 7, window_end_hour: 22, + resposta_start_hour: 0, resposta_end_hour: null, + }); + expect([k.respostaStartHour, k.respostaEndHour]).toEqual([0, 22]); }); it('o veto da RESPOSTA atrasa para a abertura da resposta, não 7h', () => { // Resposta com janela própria 9h-21h: fora dela, o adiado é 9h, não o // `window_start_hour` do disparo. É o que o dono vê no painel. - const k = knobs({ reengajarStartHour: 9, reengajarEndHour: 21 }); + const k = knobs({ respostaStartHour: 9, respostaEndHour: 21 }); const d = decidePacing({ now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: true, rng: () => 0, }); @@ -109,7 +122,7 @@ describe('janela de resposta separada da janela de disparo (0381)', () => { }); it('o veto do DISPARO continua citando a janela de disparo', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); const d = decidePacing({ now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: false, rng: () => 0, }); @@ -122,7 +135,7 @@ describe('janela de resposta separada da janela de disparo (0381)', () => { it('cap diário continua valendo na RESPOSTA — 24h não é sem limite', () => { // A janela é cortesia; o anti-ban (cap, warm-up, throttle) não abre junto. - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); const d = decidePacing({ now: AS_3H, knobs: k, state: { ...estado, sentToday: 999 }, crmDailyLimit: null, resposta: true, }); @@ -131,24 +144,33 @@ describe('janela de resposta separada da janela de disparo (0381)', () => { }); it('domingo desligado cala a resposta também (knob único, sem par)', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24, allowSunday: false }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24, allowSunday: false }); const domingo = new Date('2026-10-04T12:00:00-03:00'); // domingo expect(janelaDeEnvioAberta(domingo, k, true)).toBe(false); }); it('o padrão do repositório continua espelhando a janela de disparo', () => { - // Se este teste quebrar, todo clone que não gravou `reengajar_*` mudou de + // Se este teste quebrar, todo clone que não gravou `resposta_*` mudou de // comportamento sem ninguém pedir. - expect(PACING_DEFAULTS.reengajarStartHour).toBe(PACING_DEFAULTS.windowStartHour); - expect(PACING_DEFAULTS.reengajarEndHour).toBe(PACING_DEFAULTS.windowEndHour); + expect(PACING_DEFAULTS.respostaStartHour).toBe(PACING_DEFAULTS.windowStartHour); + expect(PACING_DEFAULTS.respostaEndHour).toBe(PACING_DEFAULTS.windowEndHour); }); it('proximaAberturaDaJanela segue a janela do tipo de envio', () => { - const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const k = knobs({ respostaStartHour: 0, respostaEndHour: 24 }); // Às 21h, para o disparo só amanhã 7h; para a resposta, amanhã 0h. const paraDisparo = proximaAberturaDaJanela(AS_21H, k, false, () => 0); const paraResposta = proximaAberturaDaJanela(AS_21H, k, true, () => 0); expect(horaNoFuso(paraDisparo)).toBe(7); expect(horaNoFuso(paraResposta)).toBe(0); }); -}); \ No newline at end of file +}); +describe('quem lê a janela de resposta (0495)', () => { + it('só o turno que reage a quem escreveu é resposta', async () => { + const { eTurnoDeResposta } = await import('@/lib/agent-engine/agent/inbound-turn'); + expect(eTurnoDeResposta({ kind: 'inbound_turn' })).toBe(true); + expect(eTurnoDeResposta({ kind: 'case_reply_turn' })).toBe(true); + // Retomar conversa parada às 4h é o que a janela de disparo existe para barrar. + expect(eTurnoDeResposta({ kind: 'followup_turn' })).toBe(false); + }); +}); diff --git a/lib/agent-engine/pacing/store.ts b/lib/agent-engine/pacing/store.ts index 14d66b8b2..e8de2ec90 100644 --- a/lib/agent-engine/pacing/store.ts +++ b/lib/agent-engine/pacing/store.ts @@ -17,9 +17,9 @@ interface ChannelKnobsRow { jitter_max_ms: number | null; window_start_hour: number | null; window_end_hour: number | null; - /** Janela da RESPOSTA do agente (0381). NULL = usa `window_*` (comportamento anterior). */ - reengajar_start_hour: number | null; - reengajar_end_hour: number | null; + /** Janela da RESPOSTA do agente (0495). NULL = usa `window_*` (comportamento anterior). */ + resposta_start_hour: number | null; + resposta_end_hour: number | null; allow_sunday: boolean | null; timezone: string | null; warmup_daily_caps: unknown; // jsonb — shape validado em parseWarmupCaps (nunca confiado) @@ -95,7 +95,7 @@ export async function loadChannelKnobs( // ainda precisa do fuso da empresa (`fusoDaJanela`). Uma ida ao banco só. const { rows } = await db.query( `select k.throttle_ms, k.jitter_max_ms, k.window_start_hour, k.window_end_hour, - k.reengajar_start_hour, k.reengajar_end_hour, + k.resposta_start_hour, k.resposta_end_hour, k.allow_sunday, k.timezone, k.warmup_daily_caps, k.number_activated_at, o.timezone as org_timezone from organizations o @@ -130,11 +130,11 @@ export async function loadChannelKnobs( windowEndHour: row.window_end_hour ?? PACING_DEFAULTS.windowEndHour, // `null` nestas duas = o número nunca foi configurado com janela de // resposta própria, e aí vale a janela de DISPARO. Sem esse `??`, um clone - // que rodou a 0381 porém nunca gravou as colunas teria resposta bloqueada + // que rodou a 0495 porém nunca gravou as colunas teria resposta bloqueada // fora de 7h-22h (o default do arquivo), que é justamente o que ele já // fazia — mas por outro caminho, e ninguém saberia dizer qual. - reengajarStartHour: row.reengajar_start_hour ?? row.window_start_hour ?? PACING_DEFAULTS.reengajarStartHour, - reengajarEndHour: row.reengajar_end_hour ?? row.window_end_hour ?? PACING_DEFAULTS.reengajarEndHour, + 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, row.org_timezone), warmupDailyCaps, diff --git a/lib/ai/pacing-knobs.ts b/lib/ai/pacing-knobs.ts index b88334edc..1bc4f0648 100644 --- a/lib/ai/pacing-knobs.ts +++ b/lib/ai/pacing-knobs.ts @@ -114,13 +114,13 @@ export const pacingKnobsUpdateSchema = z 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 (0381). `0` e `24` são valores LEGÍTIMOS — + * 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