mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user