feat(agent): janela de rajada do WhatsApp configurável por agente (0498)

O debounce de rajada (mensagens do MESMO contato dentro da janela viram uma
resposta só) era só a env do worker INBOUND_DEBOUNCE_MS (default 8000, global,
invisível na tela, exige VPS). Agora vira campo opcional por agente em
'Freios de segurança': vazio = usa a env da instalação (regressão zero),
0 desliga a coalescência, teto de 60s clampa pelo leitor (debounceEfetivo).

O drain lê a config do agente da conversa (loadConversationAgentConfig, mesma
resolução do turno) e usa o debounce do agente no lugar do literal.

DoD: migration 0498 + apêndice idempotente no baseline + linha no MANIFEST;
leitor testado (default/env, campo, teto no limite); gate de consumidor de
knob atendido.
This commit is contained in:
webtecnica
2026-09-30 10:03:31 -03:00
parent 1c8aa57222
commit 4a8a65bcdd
20 changed files with 221 additions and 10 deletions
@@ -0,0 +1,8 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Espera antes de responder (janela de rajada) configurável por agente
---
No cadastro do agente, em "Freios de segurança", agora dá para ajustar quanto tempo o agente espera antes de responder mensagens do mesmo contato que chegam em partes (ex.: "Obrigada" e, 14 s depois, "Tem piscina?"). Deixar o campo vazio usa a janela padrão da instalação (8 s), e o valor máximo é 60 segundos — Nada de travar o atendimento sem querer. Não muda nada para quem não mexer: o campo é opcional e o comportamento de antes (janela padrão da instalação) segue valendo quando ele está vazio.
Contribuição de @webtecnica.
@@ -19,7 +19,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -124,6 +124,7 @@ export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
if (patch.cases_enabled !== undefined) update.cases_enabled = patch.cases_enabled;
if (patch.split_messages !== undefined) update.split_messages = patch.split_messages;
if (patch.split_max_chars !== undefined) update.split_max_chars = patch.split_max_chars;
if (patch.inbound_debounce_ms !== undefined) update.inbound_debounce_ms = patch.inbound_debounce_ms;
if (patch.followup !== undefined) {
const existingFollowup =
existing.followup !== null &&
+2 -1
View File
@@ -23,7 +23,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -163,6 +163,7 @@ export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
cases_enabled: v.cases_enabled,
split_messages: v.split_messages,
split_max_chars: v.split_max_chars,
inbound_debounce_ms: v.inbound_debounce_ms,
followup: v.followup,
// Mesmo descarte silencioso da rota de criação de agente: o corpo aceita
// e o INSERT ignorava. Criar uma versão nova pela API com escopo ou
+1 -1
View File
@@ -48,7 +48,7 @@ const AGENT_COLUMNS_COM_VERSAO =
", versao_publicada:ai_agent_versions!ai_agents_published_version_id_fkey(provider, model)";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
// ---------------------------------------------------------------------------
// GET — list
+1 -1
View File
@@ -38,7 +38,7 @@ import { VALID_TOOL_IDS } from "@/lib/mcp/tools";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
type ActionResult<T = void> =
| { ok: true; data?: T }
@@ -184,6 +184,8 @@ interface FormState {
cases_enabled: boolean;
split_messages: boolean;
split_max_chars: number;
/** Janela de rajada (ms) do agente. `null` = usa a env da instalação. */
inbound_debounce_ms: number | null;
followup: FollowupValue;
// Papel OPERADOR (spec 16 §3.2) — o que mexe no sistema depois da conversa.
operator_enabled: boolean;
@@ -284,6 +286,7 @@ export function buildState(args: {
cases_enabled: version?.cases_enabled ?? false,
split_messages: version?.split_messages ?? false,
split_max_chars: version?.split_max_chars ?? 600,
inbound_debounce_ms: version?.inbound_debounce_ms ?? null,
followup: version?.followup
? {
...DEFAULT_FOLLOWUP,
@@ -347,6 +350,7 @@ function toVersionPayload(s: FormState) {
cases_enabled: s.cases_enabled,
split_messages: s.split_messages,
split_max_chars: s.split_max_chars,
inbound_debounce_ms: s.inbound_debounce_ms,
followup: s.followup,
operator_enabled: s.operator_enabled,
// "" (não escolheu) → null (herda o do Conversador). São o mesmo conceito em
@@ -1045,6 +1049,42 @@ export function AgentForm(props: Props) {
disabled={disabled}
/>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="inbound_debounce_ms">
{t("Esperar antes de responder (segundos)")}
</Label>
<Input
id="inbound_debounce_ms"
type="number"
min={0}
max={60}
step={1}
placeholder={t("Vazio = padrão da instalação")}
value={
form.inbound_debounce_ms === null
? ""
: String(Math.round(form.inbound_debounce_ms / 1000))
}
onChange={(e) => {
const raw = e.target.value;
// Vazio = usa a env da instalação (campo null). A UI fala em
// SEGUNDOS; o banco e o worker falam em ms (conversão aqui,
// num ponto só). Teto de 60s no campo espelha o do worker.
patch({
inbound_debounce_ms:
raw === ""
? null
: Math.max(0, Math.min(60, Number(raw))) * 1000,
});
}}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).",
)}
</p>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="history_token_window">{t("Tamanho máximo desse histórico")}</Label>
<Input
+1 -1
View File
@@ -26,7 +26,7 @@ const AGENT_COLUMNS =
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, channel, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const CREDENTIAL_COLUMNS =
"id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at";
+1
View File
@@ -32,6 +32,7 @@ export interface AgentVersionRow {
knowledge_source_ids: string[];
split_messages: boolean;
split_max_chars: number;
inbound_debounce_ms: number | null;
followup: {
enabled: boolean;
flow_pointer_ids: string[];
+5
View File
@@ -36,6 +36,8 @@ export interface PublishedAgentConfig {
proposalAiDraftEnabled: boolean;
splitMessages: boolean;
splitMaxChars: number;
/** Janela de rajada inbound (ms) configurada na versão. `null` = usa a env. */
inboundDebounceMs: number | null;
/** input multimodal (imagem/áudio/pdf) habilitado no turno (Onda 3). */
multimodalInput: boolean;
/** tools open_human_case/provide_case_update habilitadas no turno (spec 15). */
@@ -112,6 +114,7 @@ interface Row {
proposal_ai_draft_enabled: boolean;
split_messages: boolean;
split_max_chars: number;
inbound_debounce_ms: number | null;
multimodal_input: boolean;
cases_enabled: boolean;
followup: unknown;
@@ -143,6 +146,7 @@ const SELECT_AGENT_CONFIG_COLUMNS = `a.operation_mode,a.paused_at,a.operation_re
v.proposal_ai_draft_enabled,
v.split_messages,
v.split_max_chars,
v.inbound_debounce_ms,
v.multimodal_input,
v.cases_enabled,
v.followup,
@@ -200,6 +204,7 @@ function mapAgentConfigRow(r: Row): PublishedAgentConfig {
proposalAiDraftEnabled: r.proposal_ai_draft_enabled,
splitMessages: r.split_messages,
splitMaxChars: r.split_max_chars,
inboundDebounceMs: r.inbound_debounce_ms ?? null,
multimodalInput: r.multimodal_input,
casesEnabled: r.cases_enabled,
followup: r.followup,
@@ -40,6 +40,7 @@ const AGENT: PublishedAgentConfig = {
proposalAiDraftEnabled: true,
splitMessages: false,
splitMaxChars: 400,
inboundDebounceMs: null,
multimodalInput: false,
casesEnabled: false,
toolIds: [],
@@ -26,6 +26,7 @@ function fakeConfig(agentId: string): PublishedAgentConfig {
proposalAiDraftEnabled: true,
splitMessages: false,
splitMaxChars: 900,
inboundDebounceMs: null,
multimodalInput: false,
casesEnabled: false,
toolIds: [],
+27 -1
View File
@@ -1,7 +1,7 @@
import { expect, it, vi } from 'vitest';
import type pg from 'pg';
import { decidirRajada, janelaDeRajada } from './debounce';
import { decidirRajada, janelaDeRajada, debounceEfetivo, TETO_DEBOUNCE_MS } from './debounce';
const alvo = { organizationId: 'org1', contactId: 'contato1' };
/** Instante fixo: a janela é aritmética e o teste não depende do relógio. */
@@ -70,3 +70,29 @@ it('janelaDeRajada: sem debounce não há janela; com debounce a janela é agora
expect(janelaDeRajada(0, AGORA)).toBeUndefined();
expect(janelaDeRajada(750, AGORA)?.getTime()).toBe(AGORA + 750);
});
// ── Leitor de debounce configurável por agente (issue #1856) ────────────
const ENV = 8_000;
it('debounceEfetivo: campo vazio (null) usa o default da instalação — regressão zero', () => {
expect(debounceEfetivo(null, ENV)).toBe(ENV);
expect(debounceEfetivo(undefined, ENV)).toBe(ENV);
});
it('debounceEfetivo: campo preenchido vence o default', () => {
expect(debounceEfetivo(20_000, ENV)).toBe(20_000);
});
it('debounceEfetivo: 0 desliga a coalescência — não herda nem clampa', () => {
expect(debounceEfetivo(0, ENV)).toBe(0);
});
it('debounceEfetivo: teto de 60s — valor ACIMA do teto é clamado no limite', () => {
expect(TETO_DEBOUNCE_MS).toBe(60_000);
expect(debounceEfetivo(60_000, ENV)).toBe(60_000);
expect(debounceEfetivo(90_000, ENV)).toBe(60_000);
});
it('debounceEfetivo: valor NEGATIVO (dado sujo) clampa para 0, não quebra', () => {
expect(debounceEfetivo(-5, ENV)).toBe(0);
});
+23
View File
@@ -69,6 +69,29 @@ export function janelaDeRajada(debounceMs: number, agora: number = Date.now()):
return debounceMs > 0 ? new Date(agora + debounceMs) : undefined;
}
/**
* Teto da janela de rajada configurável por agente (#1856): não deixar ninguém
* travar o atendimento sem querer. Vale também como default de tela/UX.
*/
export const TETO_DEBOUNCE_MS = 60_000;
/**
* Janela de rajada EFETIVA de um agente.
*
* `null`/`undefined` (campo vazio na versão) = o `INBOUND_DEBOUNCE_MS` da
* instalação — regressão zero para quem nunca mexeu no campo. O valor
* configurado é CLAMPADO em [0, TETO_DEBOUNCE_MS]: 0 desliga a coalescência e
* valores acima do teto (dado sujo que entrou por fora da validação) caem para
* 60s em vez de travar o atendimento.
*/
export function debounceEfetivo(
configurado: number | null | undefined,
padraoInstalacao: number,
): number {
if (configurado === null || configurado === undefined) return padraoInstalacao;
return Math.min(Math.max(0, configurado), TETO_DEBOUNCE_MS);
}
/** Decide entre carona em job existente e janela nova. */
export async function decidirRajada(
pool: pg.Pool,
+28 -2
View File
@@ -15,10 +15,11 @@
import { z } from 'zod';
import type pg from 'pg';
import { loadConversationAgentConfig } from '../../agent/agent-config';
import { insertInboxItem } from '../../db/repository';
import type { Logger } from '../../obs/logger';
import { enqueueJob } from '../../queue/queue';
import { decidirRajada } from './debounce';
import { decidirRajada, debounceEfetivo } from './debounce';
import { avisoDeEventoMorto, IA_QUE_NAO_RESPONDEU } from '@/lib/event-log/aviso-de-evento-morto';
import { TIPOS_DERIVAVEIS, DERIVACAO_TERMINADA } from '@/lib/messaging/media/derivable';
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
@@ -197,6 +198,31 @@ const TETO_ESPERA_DERIVACAO_MS = 120_000;
type DesfechoEvento = 'processado' | 'adiar';
/**
* Janela de rajada EFETIVA para o evento: a configurada na versão do agente
* desta conversa (#1856), com o `INBOUND_DEBOUNCE_MS` da instalação como
* default e clamp no teto de 60s (`debounceEfetivo`).
*
* Usa a MESMA resolução que o turno (`loadConversationAgentConfig`): respeta o
* `active_ai_agent_id` da conversa quando há dono explícito e cai no agente
* publicado da sessão quando não. Com o campo vazio (default de toda
* instalação) a consulta volta `null` e o valor vira o da env — regressão zero.
*/
async function debounceDoEvento(
pool: pg.Pool,
event: EventRow,
p: { conversation_id: string; channel_session_id: string },
padraoInstalacao: number,
): Promise<number> {
const agentConfig = await loadConversationAgentConfig(
pool,
event.organization_id,
p.conversation_id,
p.channel_session_id,
);
return debounceEfetivo(agentConfig?.inboundDebounceMs ?? null, padraoInstalacao);
}
async function processEvent(
pool: pg.Pool,
event: EventRow,
@@ -485,7 +511,7 @@ async function processEvent(
const rajada = await decidirRajada(
pool,
{ organizationId: event.organization_id, contactId: p.contact_id },
knobs.debounceMs,
await debounceDoEvento(pool, event, p, knobs.debounceMs),
);
if (rajada.tipo === 'coalescido') {
log.info('drain: rajada coalescida em job pendente', {
+2 -1
View File
@@ -24,7 +24,7 @@ export const DUPLICATE_AGENT_COLUMNS =
* basta, se o INSERT não a escreve a cópia nasce com o default do banco.
*/
export const DUPLICATE_VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
export type DuplicateAgentError =
| "not_found"
@@ -78,6 +78,7 @@ function versionPayloadFrom(src: Record<string, unknown>) {
operator_tool_ids: src.operator_tool_ids,
split_messages: src.split_messages,
split_max_chars: src.split_max_chars,
inbound_debounce_ms: src.inbound_debounce_ms ?? null,
followup: src.followup,
// ESCOPO. As duas faltavam — `pipeline_ids` desde a 0125, e o cabeçalho
// deste arquivo já mandava ("coluna nova entra aqui E em
+9
View File
@@ -169,6 +169,15 @@ const versionShapeSchema = z
// pelo pacing anti-ban. Defaults espelham a migration 0059.
split_messages: z.boolean().default(false),
split_max_chars: z.number().int().min(80).max(4000).default(600),
/**
* Janela de coalescência de rajada inbound para ESTE agente (ms).
*
* `null`/ausente = usa o `INBOUND_DEBOUNCE_MS` da instalação (comportamento
* de sempre — regressão zero). 0 desliga a coalescência de rajada para o
* agente (job imediato). 1..60000 define a janela, com TETO de 60s para
* ninguém travar o atendimento sem querer (#1856).
*/
inbound_debounce_ms: z.number().int().min(0).max(60000).nullable().optional(),
followup: followupConfigSchema,
// ── Papel OPERADOR (spec 16 §3.2) ───────────────────────────────────────
// Todos com `.default(...)`, e é o que mantém retrocompatível: agent e
+5
View File
@@ -53,6 +53,7 @@ export const DICIONARIO: Traducoes = {
"Enriquecendo…": {"es": "Completando…"},
"Enriquecer CNPJ": {"es": "Completar por CNPJ"},
"Enriquecimento": {"es": "Datos de BrasilAPI"},
"Esperar antes de responder (segundos)": {"es": "Esperar antes de responder (segundos)"},
"Falha na importação.": {"es": "Falló la importación."},
"Importações": {"es": "Importaciones"},
"Linhas": {"es": "Filas"},
@@ -1722,6 +1723,10 @@ export const DICIONARIO: Traducoes = {
"Volume de texto por atendimento": { es: "Volumen de texto por atención" },
"Custo máximo por atendimento (centavos)": { es: "Costo máximo por atención (centavos)" },
"Mensagens anteriores que ele lê": { es: "Mensajes anteriores que lee" },
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).": {
es: "Los mensajes del mismo contacto dentro de este tiempo se convierten en una sola respuesta. Vacío usa la ventana predeterminada de la instalación (máximo 60 segundos).",
},
"Vazio = padrão da instalação": { es: "Vacío = predeterminado de la instalación" },
"Tamanho máximo desse histórico": { es: "Tamaño máximo de ese historial" },
"As instruções dele": { es: "Sus instrucciones" },
"Estilo de resposta": { es: "Estilo de respuesta" },
+23 -1
View File
@@ -1003,17 +1003,39 @@ CREATE TABLE IF NOT EXISTS "public"."ai_agent_versions" (
"superseded_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"created_by" "uuid",
"inbound_debounce_ms" integer,
CONSTRAINT "ai_agent_versions_cost_budget_cents_check" CHECK ((("cost_budget_cents" >= 1) AND ("cost_budget_cents" <= 10000))),
CONSTRAINT "ai_agent_versions_max_steps_check" CHECK ((("max_steps" >= 1) AND ("max_steps" <= 25))),
CONSTRAINT "ai_agent_versions_provider_check" CHECK (("provider" = ANY (ARRAY['anthropic'::"text", 'openai'::"text", 'google'::"text"]))),
CONSTRAINT "ai_agent_versions_status_check" CHECK (("status" = ANY (ARRAY['draft'::"text", 'published'::"text", 'superseded'::"text", 'archived'::"text"]))),
CONSTRAINT "ai_agent_versions_token_budget_check" CHECK ((("token_budget" >= 1000) AND ("token_budget" <= 500000)))
CONSTRAINT "ai_agent_versions_token_budget_check" CHECK ((("token_budget" >= 1000) AND ("token_budget" <= 500000))),
CONSTRAINT "ai_agent_versions_inbound_debounce_ms_check" CHECK ((("inbound_debounce_ms" IS NULL) OR (("inbound_debounce_ms" >= 0) AND ("inbound_debounce_ms" <= 60000))))
);
ALTER TABLE "public"."ai_agent_versions" OWNER TO "postgres";
-- ═══ Apêndice 0498 — debounce de rajada configurável por agente ═══
-- O kit self-host aplica SÓ o baseline: quem já instalou antes da 0498 não tem
-- a coluna na tabela (o `create table if not exists` acima não a altera).
-- Este apêndice idempotente é o que leva a coluna nova à instalação existente
-- no `update.sh`, junto do par drop/add da CHECK (o 0498 MP pode já ter sido
-- aplicado e a constraint sem guarda quebraria com 'already exists').
alter table public."ai_agent_versions"
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
alter table public."ai_agent_versions"
add column if not exists inbound_debounce_ms integer;
comment on column public."ai_agent_versions".inbound_debounce_ms is
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
alter table public."ai_agent_versions"
add constraint ai_agent_versions_inbound_debounce_ms_check
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
CREATE TABLE IF NOT EXISTS "public"."ai_agents" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"organization_id" "uuid" NOT NULL,
@@ -0,0 +1,40 @@
-- ═══ Debounce de rajada configurável por agente (0498) ═══
--
-- Até aqui a janela que junta as mensagens do MESMO contato numa resposta só
-- era só a env do worker `INBOUND_DEBOUNCE_MS` (default 8000, em
-- lib/agent-engine/env.ts): global à instalação, invisível na tela e exigindo
-- acesso à VPS para mudar. Esta migration leva o valor para a versão do agente
-- (`ai_agent_versions.inbound_debounce_ms`).
--
-- Sem semântica explícita é fácil errar o desenho, então a regra fica escrita:
-- - NULL (default) = usa o `INBOUND_DEBOUNCE_MS` da instalação. Quem só
-- atualiza não muda de comportamento nenhum — regressão zero.
-- - 0 desliga a coalescência de rajada para aquele agente (job imediato).
-- - 1..60000 define a janela em milissegundos, com TETO em 60s para ninguém
-- travar o atendimento sem querer.
-- O teto é reforçado no leitor do worker (`debounceEfetivo` em debounce.ts):
-- a constraint abaixo é a cerca do banco, mas quem decide de verdade é o
-- TypeScript, que clampa o valor contra o teto mesmo para dados sujos.
--
-- Por que coluna e não jsonb: o resto da config "Limites do atendimento" da
-- versão (`max_steps`, `token_budget`, `history_message_window`, …) vive em
-- colunas tipadas nesta tabela, e o PATCH/INSERT da versão as mapeia campo a
-- campo. Nascer jsonb aqui seria divergir do vizinho sem CHECK forte nenhum.
alter table public.ai_agent_versions
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
alter table public.ai_agent_versions
add column if not exists inbound_debounce_ms integer;
comment on column public.ai_agent_versions.inbound_debounce_ms is
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
-- 0..60000 (0 desliga; 60000 = 60s, o teto que impede travar o atendimento).
-- O `drop … if exists` antes do `add` torna a migration reaplicável: o
-- `update.sh` de quem já aplicou a 0498 roda o apêndice do baseline de novo, e
-- `add constraint` sem guarda quebraria com 'already exists'. Mesmo par no
-- apêndice do baseline.sql.
alter table public.ai_agent_versions
add constraint ai_agent_versions_inbound_debounce_ms_check
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
+1
View File
@@ -485,3 +485,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260930010300` | `0494_lgpd_cascata_banco_alcanca_notas_itens` | **A cascata do BANCO alcança `lead_notes`, `ai_agent_runs.tool_calls`, `lead_state` e `contacts.social_identity` (issue #1964, follow-up do #1958).** O gatilho da virada de `is_anonymized` (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: `lead_notes.headline`/`body` (e `embedding`) → `(anonimizado)`, `ai_agent_runs.tool_calls` → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha `redigirToolCalls`/`serialize.ts`, com `redacted = true` em todo passo), `lead_state.next_action` → `null` e `qualification` → `{}`, e `contacts.social_identity` → `null` (o índice único parcial `where social_identity is not null` torna anular seguro). Filtra organização **e** contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. `fn_lgpd_redigir_tool_calls(jsonb)` é função nova `immutable` revogada de `anon`/`authenticated` (único chamador é o gatilho, `security definer` de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no `baseline.sql` antes da VARREDURA anon. Gate: `tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts`. |
| `20260930120000` | `0495_janela_de_resposta_separada` | **A janela de RESPOSTA sai da janela de DISPARO (#1983, de @suporteubere99-coder).** As duas coisas eram regidas por UM par de horas em `channel_knobs` (`window_start_hour`/`window_end_hour`, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa, a prospecção e o `followup_turn` (retomada de conversa parada). Duas colunas nullable, `resposta_start_hour`/`resposta_end_hour`, sem DEFAULT: **NULL herda a janela de disparo, coluna a coluna** (`loadChannelKnobs` em `lib/agent-engine/pacing/store.ts` e `effectiveKnobs` em `lib/ai/pacing-knobs.ts`, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com `drop … if exists` antes do `add`. Um bloco `do` guardado renomeia `reengajar_start_hour`/`reengajar_end_hour` para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em `PacingInput.resposta` (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só `inbound_turn`/`case_reply_turn` passam `true` (`eTurnoDeResposta` em `inbound-turn.ts`), inclusive na pergunta do roteiro que sai no mesmo turno. `nextDayOpen` segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` e `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (0/24 aceito, 22/7 recusado com 422). |
| `20260930140000` | `0497_lgpd_anonimizar_apaga_transcricao` | **Anonimizar apaga também a TRANSCRIÇÃO da mídia (`messages.media_derived_text`).** Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo `media-derive-worker`) não era zerada por nenhum caminho: o body do áudio virava `[mensagem anonimizada]` e a transcrição continuava legível para o agente. Uma linha `media_derived_text = null` em cada UPDATE de messages de `fn_redigir_conversas_ao_anonimizar` (corpo da 0494, 2 passadas) e de `fn_lgpd_cascade_redact_contact` (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de `lib/lgpd/cascata.ts` ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. |
| `20260930121224` | `0498_debounce_rajada_configuravel` | **A janela de rajada do WhatsApp vira campo da versão do agente (#1856).** A janela que junta mensagens do MESMO contato numa resposta só era só a env do worker `INBOUND_DEBOUNCE_MS` (global, invisível na tela, exige VPS). Coluna `ai_agent_versions.inbound_debounce_ms` (integer, sem DEFAULT): NULL = usa a env da instalação (regressão zero para quem só atualiza), 0 desliga a coalescência, 1..60000 define a janela em ms com TETO de 60s — o teto é reforçado no leitor do worker (`debounceEfetivo` em `lib/agent-engine/edge/crm/debounce.ts`) e com CHECK `0..60000` no banco (par drop/add pra reaplicar). O drain lê a config do agente da conversa (`loadConversationAgentConfig`) e usa o valor do agente no lugar do literal. Gate: `lib/agent-engine/edge/crm/debounce.test.ts` (default = env, campo = valor, teto clamp no limite). |