diff --git a/.changes/espera-debounce-configuravel-por-agente.md b/.changes/espera-debounce-configuravel-por-agente.md new file mode 100644 index 000000000..217dc745e --- /dev/null +++ b/.changes/espera-debounce-configuravel-por-agente.md @@ -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. \ No newline at end of file diff --git a/app/api/v1/ai/agents/[id]/versions/[vid]/route.ts b/app/api/v1/ai/agents/[id]/versions/[vid]/route.ts index d22e87a0f..1609cd7d6 100644 --- a/app/api/v1/ai/agents/[id]/versions/[vid]/route.ts +++ b/app/api/v1/ai/agents/[id]/versions/[vid]/route.ts @@ -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 { 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 && diff --git a/app/api/v1/ai/agents/[id]/versions/route.ts b/app/api/v1/ai/agents/[id]/versions/route.ts index 05f00636b..42b3c3eb4 100644 --- a/app/api/v1/ai/agents/[id]/versions/route.ts +++ b/app/api/v1/ai/agents/[id]/versions/route.ts @@ -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 { 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 diff --git a/app/api/v1/ai/agents/route.ts b/app/api/v1/ai/agents/route.ts index b789eaaaf..72f50cc16 100644 --- a/app/api/v1/ai/agents/route.ts +++ b/app/api/v1/ai/agents/route.ts @@ -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 diff --git a/app/app/ai/agents/[id]/_actions.ts b/app/app/ai/agents/[id]/_actions.ts index 9279f5a5c..2bd77593c 100644 --- a/app/app/ai/agents/[id]/_actions.ts +++ b/app/app/ai/agents/[id]/_actions.ts @@ -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 = | { ok: true; data?: T } diff --git a/app/app/ai/agents/[id]/_components/AgentForm.tsx b/app/app/ai/agents/[id]/_components/AgentForm.tsx index 1a38fb66a..e050844d7 100644 --- a/app/app/ai/agents/[id]/_components/AgentForm.tsx +++ b/app/app/ai/agents/[id]/_components/AgentForm.tsx @@ -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} /> +
+ + { + 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} + /> +

+ {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).", + )} +

+
{ + 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); +}); diff --git a/lib/agent-engine/edge/crm/debounce.ts b/lib/agent-engine/edge/crm/debounce.ts index 4f406b58c..614d92f71 100644 --- a/lib/agent-engine/edge/crm/debounce.ts +++ b/lib/agent-engine/edge/crm/debounce.ts @@ -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, diff --git a/lib/agent-engine/edge/crm/drain.ts b/lib/agent-engine/edge/crm/drain.ts index b3d458e3f..0ce60acff 100644 --- a/lib/agent-engine/edge/crm/drain.ts +++ b/lib/agent-engine/edge/crm/drain.ts @@ -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 { + 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', { diff --git a/lib/ai/agents/duplicate.ts b/lib/ai/agents/duplicate.ts index f319ffb42..8def57139 100644 --- a/lib/ai/agents/duplicate.ts +++ b/lib/ai/agents/duplicate.ts @@ -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) { 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 diff --git a/lib/ai/agents/validation.ts b/lib/ai/agents/validation.ts index e4ba65d89..fb5736b07 100644 --- a/lib/ai/agents/validation.ts +++ b/lib/ai/agents/validation.ts @@ -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 diff --git a/lib/i18n/dicionario.ts b/lib/i18n/dicionario.ts index ae14fa17d..13d5bf40f 100644 --- a/lib/i18n/dicionario.ts +++ b/lib/i18n/dicionario.ts @@ -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" }, diff --git a/supabase/baseline.sql b/supabase/baseline.sql index df5efe542..3edd04399 100644 --- a/supabase/baseline.sql +++ b/supabase/baseline.sql @@ -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, diff --git a/supabase/migrations/20260930121224_0498_debounce_rajada_configuravel.sql b/supabase/migrations/20260930121224_0498_debounce_rajada_configuravel.sql new file mode 100644 index 000000000..882663d24 --- /dev/null +++ b/supabase/migrations/20260930121224_0498_debounce_rajada_configuravel.sql @@ -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)); \ No newline at end of file diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index a89375368..a43dd2cad 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -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). |