mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
fix(pacing): a janela de resposta do #1983 compila e se chama resposta_*
- app/api/v1/ai/pacing/route.ts: um "\n" escrito como texto punha o comentario e a lista de colunas numa linha so de comentario, e KNOB_COLUMNS engolia a funcao lerFusoDaOrganizacao (GET/PUT quebrados). A validacao do par de resposta perde o desvio redundante de 0/24. - reengajar_* vira resposta_*: a coluna guarda a janela de RESPOSTA, e reengajar e justamente o que ela nao rege. Migration e apendice ganham um bloco do guardado que renomeia onde o nome antigo ja foi aplicado. - PacingKnobs ganhou 2 campos obrigatorios: janela-do-canal.ts e um teste montavam o literal sem eles. - A pergunta do roteiro que sai no mesmo turno le a janela de resposta (eTurnoDeResposta, agora exportada e testada). - Sai a guarda morta de janelaDoPacing: o fallback real e coluna a coluna na leitura (store/effectiveKnobs), e os testes passam a exercitar esse caminho em vez de undefined forjado. - Comentarios: 0381 -> 0495; some o "DEFAULT 9h-21h / cutucar" que descrevia o inverso do codigo. Co-authored-by: suporteubere99-coder <267519567+suporteubere99-coder@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
suporteubere99-coder
Claude Opus 5.5
parent
6d40459305
commit
b740def16f
@@ -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 = '<sua org>' and channel_session_id = '<seu número>';
|
||||
|
||||
-- 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 = '<sua org>' and channel_session_id = '<seu número>';
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
);
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -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<JobRow, 'kind'>): 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChannelKnobsRow>(
|
||||
`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,
|
||||
|
||||
@@ -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<end é `windowIsValid` sobre o par
|
||||
* RESULTANTE, depois de mesclar com o que já está gravado.
|
||||
*/
|
||||
reengajar_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(),
|
||||
reengajar_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(),
|
||||
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()
|
||||
@@ -171,9 +171,9 @@ export interface ChannelKnobsRow {
|
||||
jitter_max_ms: number | null;
|
||||
window_start_hour: number | null;
|
||||
window_end_hour: number | null;
|
||||
/** Janela da RESPOSTA (0381). Ausente/null = herda a janela de disparo. */
|
||||
reengajar_start_hour?: number | null;
|
||||
reengajar_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;
|
||||
allow_sunday: boolean | null;
|
||||
timezone: string | null;
|
||||
warmup_daily_caps: unknown;
|
||||
@@ -205,8 +205,8 @@ export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string |
|
||||
// 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.
|
||||
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, fusoDaOrg),
|
||||
warmupDailyCaps: parseWarmupCaps(row?.warmup_daily_caps) ?? PACING_DEFAULTS.warmupDailyCaps,
|
||||
|
||||
@@ -91,6 +91,10 @@ export async function knobsDoCanal(
|
||||
jitterMaxMs: linha.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
|
||||
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
|
||||
// de disparo só para o tipo fechar, sem ir buscar `resposta_*` no banco.
|
||||
respostaStartHour: linha.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
|
||||
respostaEndHour: linha.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
|
||||
allowSunday: linha.allow_sunday ?? PACING_DEFAULTS.allowSunday,
|
||||
timezone: fusoDaJanela(linha.timezone, fusoDaOrg),
|
||||
warmupDailyCaps: caps ?? PACING_DEFAULTS.warmupDailyCaps,
|
||||
|
||||
+43
-21
@@ -44545,29 +44545,51 @@ end
|
||||
$pub$;
|
||||
|
||||
-- ---- janela de RESPOSTA separada da janela de DISPARO (migration 0495) ----
|
||||
-- O dono pediu: o agente responde a qualquer hora, mas NADA de disparo em massa
|
||||
-- nem cutucar conversa parada fora do horário comercial. As duas coisas eram
|
||||
-- regidas por UM knob (`window_start_hour`/`window_end_hour`), então abrir a
|
||||
-- janela do agente para 24h abriria também a do disparo.
|
||||
-- O agente passa a poder responder a quem escreveu fora do horário comercial sem
|
||||
-- abrir junto o disparo em massa, a prospecção e a retomada de conversa parada.
|
||||
-- As duas coisas eram regidas por UM par (`window_start_hour`/`window_end_hour`).
|
||||
--
|
||||
-- Colunas soltas, não jsonb: `window_*_hour` é coluna desde a 0010 e a tela de
|
||||
-- Conexões já os edita; um `reengajar_knobs` jsonb nasceria sem CHECK forte e
|
||||
-- Conexões já os edita; um `resposta_knobs` jsonb nasceria sem CHECK forte e
|
||||
-- divergiria do vizinho na mesma tabela.
|
||||
--
|
||||
-- ⚠️ NULL = conserva o comportamento de HOJE (o agente espera na janela do
|
||||
-- disparo). Um clone que nunca gravou estas colunas não muda de comportamento por
|
||||
-- causa desta migration — e é por isso que o default é NULL e não 0/24.
|
||||
--
|
||||
-- O padrão DOCUMENTADO de `reengajar_*` é 9h-21h, mais apertado que o disparo
|
||||
-- (7h-22h) porque cutucar quem SUMIU é o que mais incomoda: essa mensagem chega
|
||||
-- para alguém que não pediu nada. Quem preferir igualar ao disparo grava 7 e 22.
|
||||
alter table public.channel_knobs
|
||||
add column if not exists reengajar_start_hour smallint,
|
||||
add column if not exists reengajar_end_hour smallint;
|
||||
-- ⚠️ Sem DEFAULT: NULL = a resposta herda a janela de disparo, coluna a coluna,
|
||||
-- que é o comportamento de antes. Quem só atualiza não muda de operação.
|
||||
|
||||
comment on column public.channel_knobs.reengajar_start_hour is
|
||||
-- A primeira versão desta migration (PR #1983, fechado sem merge) chamava as
|
||||
-- colunas `reengajar_*`. Quem já a aplicou tem os dados lá: renomeia em vez de
|
||||
-- criar coluna nova ao lado, e a constraint de nome velho sai junto.
|
||||
do $renomear_reengajar$
|
||||
begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'reengajar_start_hour')
|
||||
and not exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'resposta_start_hour') then
|
||||
alter table public.channel_knobs rename column reengajar_start_hour to resposta_start_hour;
|
||||
end if;
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'reengajar_end_hour')
|
||||
and not exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'resposta_end_hour') then
|
||||
alter table public.channel_knobs rename column reengajar_end_hour to resposta_end_hour;
|
||||
end if;
|
||||
end
|
||||
$renomear_reengajar$;
|
||||
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_reengajar_horas_validas;
|
||||
|
||||
alter table public.channel_knobs
|
||||
add column if not exists resposta_start_hour smallint,
|
||||
add column if not exists resposta_end_hour smallint;
|
||||
|
||||
comment on column public.channel_knobs.resposta_start_hour is
|
||||
'Início da janela de RESPOSTA do agente (h, hora local da org). NULL = usa window_start_hour (comportamento anterior).';
|
||||
comment on column public.channel_knobs.reengajar_end_hour is
|
||||
comment on column public.channel_knobs.resposta_end_hour is
|
||||
'Fim da janela de RESPOSTA do agente (h, exclusivo; 24 = meia-noite). NULL = usa window_end_hour.';
|
||||
|
||||
-- 0..24. `end` pode ser 24 (meia-noite seguinte) porque `insideWindow` compara
|
||||
@@ -44577,12 +44599,12 @@ comment on column public.channel_knobs.reengajar_end_hour is
|
||||
-- sem guarda quebra com 'already exists' no segundo clone que atualizar. É o
|
||||
-- gate `tests/unit/baseline-reaplicavel.test.ts` que cobra esta forma.
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_reengajar_horas_validas;
|
||||
drop constraint if exists channel_knobs_resposta_horas_validas;
|
||||
alter table public.channel_knobs
|
||||
add constraint channel_knobs_reengajar_horas_validas
|
||||
add constraint channel_knobs_resposta_horas_validas
|
||||
check (
|
||||
(reengajar_start_hour is null or reengajar_start_hour between 0 and 23)
|
||||
and (reengajar_end_hour is null or reengajar_end_hour between 1 and 24)
|
||||
(resposta_start_hour is null or resposta_start_hour between 0 and 23)
|
||||
and (resposta_end_hour is null or resposta_end_hour between 1 and 24)
|
||||
);
|
||||
|
||||
-- ---- dedupe de event_dead atômico: índice único parcial (migration 0491) ----
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
-- ═══ Janela de RESPOSTA separada da janela de DISPARO (0495) ═══
|
||||
--
|
||||
-- O dono pediu: o agente responde a qualquer hora do dia, mas NADA de disparo
|
||||
-- em massa nem cutucar conversa parada fora do horário comercial. As duas coisas
|
||||
-- regidas por UM knob (`window_start_hour`/`window_end_hour`), então abrir a
|
||||
-- janela do agente para 24h abriria também a do disparo.
|
||||
-- O agente passa a poder responder a quem escreveu fora do horário comercial
|
||||
-- sem abrir junto o disparo em massa, a prospecção e a retomada de conversa
|
||||
-- parada. As duas coisas eram regidas por UM par (`window_start_hour`/
|
||||
-- `window_end_hour`), então abrir a resposta para 24h abria também o disparo.
|
||||
--
|
||||
-- Por que colunas soltas e não um jsonb: o projeto trata `window_*_hour` como
|
||||
-- coluna desde a 0010 e a tela de Conexões já os edita. `reengajar_knobs`
|
||||
-- coluna desde a 0010 e a tela de Conexões já os edita. Um `resposta_knobs`
|
||||
-- nasceria jsonb sem CHECK forte e divergiria do vizinho na mesma tabela.
|
||||
--
|
||||
-- O DEFAULT de `reengajar_start_hour` é 9 e `reengajar_end_hour` é 21 — mais
|
||||
-- apertado que o disparo (7h–22h) porque cutucar quem SUMIU é o que mais
|
||||
-- incomoda: essa mensagem chega para alguém que não pediu nada. Quem preferir
|
||||
-- igualar ao disparo grava 7 e 22.
|
||||
--
|
||||
-- ⚠️ NULL = conserva o comportamento de HOJE (o agente espera na janela do
|
||||
-- disparo). Um clone que nunca gravou estas colunas não muda de comportamento
|
||||
-- por causa desta migration — e é por isso que o default é NULL e não 0/24.
|
||||
alter table channel_knobs
|
||||
add column if not exists reengajar_start_hour smallint,
|
||||
add column if not exists reengajar_end_hour smallint;
|
||||
-- ⚠️ Sem DEFAULT: NULL = a resposta herda a janela de disparo, coluna a coluna,
|
||||
-- que é o comportamento de antes. Quem só atualiza não muda de operação.
|
||||
|
||||
comment on column channel_knobs.reengajar_start_hour is
|
||||
-- A primeira versão desta migration (PR #1983, fechado sem merge) chamava as
|
||||
-- colunas `reengajar_*`. Quem já a aplicou tem os dados lá: renomeia em vez de
|
||||
-- criar coluna nova ao lado, e a constraint de nome velho sai junto.
|
||||
do $renomear_reengajar$
|
||||
begin
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'reengajar_start_hour')
|
||||
and not exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'resposta_start_hour') then
|
||||
alter table public.channel_knobs rename column reengajar_start_hour to resposta_start_hour;
|
||||
end if;
|
||||
if exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'reengajar_end_hour')
|
||||
and not exists (select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'channel_knobs'
|
||||
and column_name = 'resposta_end_hour') then
|
||||
alter table public.channel_knobs rename column reengajar_end_hour to resposta_end_hour;
|
||||
end if;
|
||||
end
|
||||
$renomear_reengajar$;
|
||||
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_reengajar_horas_validas;
|
||||
|
||||
alter table public.channel_knobs
|
||||
add column if not exists resposta_start_hour smallint,
|
||||
add column if not exists resposta_end_hour smallint;
|
||||
|
||||
comment on column public.channel_knobs.resposta_start_hour is
|
||||
'Início da janela de RESPOSTA do agente (h, hora local da org). NULL = usa window_start_hour (comportamento anterior).';
|
||||
comment on column channel_knobs.reengajar_end_hour is
|
||||
comment on column public.channel_knobs.resposta_end_hour is
|
||||
'Fim da janela de RESPOSTA do agente (h, exclusivo; 24 = meia-noite). NULL = usa window_end_hour.';
|
||||
|
||||
-- 0..24. `end` pode ser 24 (meia-noite seguinte) porque `insideWindow` compara
|
||||
@@ -31,11 +53,11 @@ comment on column channel_knobs.reengajar_end_hour is
|
||||
-- O `drop … if exists` antes do `add` e o que torna a migration reaplicavel:
|
||||
-- o `update.sh` de quem ja aplicou a 0495 roda o apendice do baseline de novo, e
|
||||
-- `add constraint` sem guarda quebra com 'already exists'. Mesmo par no baseline.
|
||||
alter table channel_knobs
|
||||
drop constraint if exists channel_knobs_reengajar_horas_validas;
|
||||
alter table channel_knobs
|
||||
add constraint channel_knobs_reengajar_horas_validas
|
||||
alter table public.channel_knobs
|
||||
drop constraint if exists channel_knobs_resposta_horas_validas;
|
||||
alter table public.channel_knobs
|
||||
add constraint channel_knobs_resposta_horas_validas
|
||||
check (
|
||||
(reengajar_start_hour is null or reengajar_start_hour between 0 and 23)
|
||||
and (reengajar_end_hour is null or reengajar_end_hour between 1 and 24)
|
||||
);
|
||||
(resposta_start_hour is null or resposta_start_hour between 0 and 23)
|
||||
and (resposta_end_hour is null or resposta_end_hour between 1 and 24)
|
||||
);
|
||||
|
||||
@@ -86,6 +86,8 @@ const KNOBS: PacingKnobs = {
|
||||
jitterMaxMs: 800,
|
||||
windowStartHour: 7,
|
||||
windowEndHour: 22,
|
||||
respostaStartHour: 7,
|
||||
respostaEndHour: 22,
|
||||
allowSunday: true,
|
||||
timezone: "America/Sao_Paulo",
|
||||
warmupDailyCaps: [
|
||||
|
||||
Reference in New Issue
Block a user