Skip to content

Commit 1d64233

Browse files
authored
Merge branch 'main' into fix/chave-openai-do-env
2 parents 7011381 + a4fdafd commit 1d64233

6 files changed

Lines changed: 270 additions & 5 deletions

File tree

app/api/v1/ai/pacing/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
knobsView,
2020
effectiveKnobs,
2121
windowIsValid,
22+
WARMUP_PULADO,
2223
type ChannelKnobsRow,
2324
} from "@/lib/ai/pacing-knobs";
2425

@@ -80,7 +81,16 @@ export async function PUT(req: NextRequest): Promise<Response> {
8081
details: parsed.error.flatten(),
8182
});
8283
}
83-
const { channel_session_id, daily_message_limit, ...knobFields } = parsed.data;
84+
const { channel_session_id, daily_message_limit, skip_warmup, ...camposDiretos } = parsed.data;
85+
// `skip_warmup` é pergunta da TELA; a coluna guarda a forma que o motor lê.
86+
// A tradução mora aqui, num lugar só: a tela não deveria precisar conhecer o
87+
// formato dos degraus para dizer "este número já está aquecido".
88+
const knobFields = {
89+
...camposDiretos,
90+
...(skip_warmup !== undefined
91+
? { warmup_daily_caps: skip_warmup ? [...WARMUP_PULADO] : null }
92+
: {}),
93+
};
8494

8595
const admin = createAdminClient();
8696
const { data: session } = await admin

components/connections/AntiBanSheet.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ interface FormState {
3737
daily_message_limit: string;
3838
allow_sunday: boolean;
3939
timezone: string;
40+
/** `yyyy-mm-dd` do input date; '' = não declarado (o motor trata como idade 0). */
41+
numero_em_uso_desde: string;
42+
pular_aquecimento: boolean;
4043
}
4144

4245
function fromItem(item: PacingKnobsItem): FormState {
@@ -52,6 +55,10 @@ function fromItem(item: PacingKnobsItem): FormState {
5255
: "",
5356
allow_sunday: o?.allow_sunday ?? item.defaults.allowSunday,
5457
timezone: o?.timezone ?? "",
58+
numero_em_uso_desde: item.warmup.number_activated_at
59+
? item.warmup.number_activated_at.slice(0, 10)
60+
: "",
61+
pular_aquecimento: item.warmup.skipped,
5562
};
5663
}
5764

@@ -90,6 +97,14 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
9097
...(form.daily_message_limit.trim() !== ""
9198
? { daily_message_limit: Math.round(Number(form.daily_message_limit)) }
9299
: {}),
100+
// Meio-dia UTC, não meia-noite: a data é um DIA declarado pelo operador, e
101+
// meia-noite vira o dia anterior em qualquer fuso a oeste — o número
102+
// envelheceria um dia a menos do que ele informou.
103+
number_activated_at:
104+
form.numero_em_uso_desde.trim() === ""
105+
? null
106+
: new Date(`${form.numero_em_uso_desde}T12:00:00.000Z`).toISOString(),
107+
skip_warmup: form.pular_aquecimento,
93108
});
94109
toast.success("Proteção de envio atualizada.");
95110
onClose();
@@ -110,6 +125,43 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
110125
</SheetHeader>
111126

112127
<div className="flex flex-col gap-5 px-4 py-2" data-testid="anti-ban-form">
128+
<fieldset className="flex flex-col gap-2" data-testid="aquecimento">
129+
<Label htmlFor="numero-em-uso-desde">Este número é usado desde</Label>
130+
<Input
131+
id="numero-em-uso-desde"
132+
type="date"
133+
max={new Date().toISOString().slice(0, 10)}
134+
value={form.numero_em_uso_desde}
135+
onChange={(e) => set({ numero_em_uso_desde: e.target.value })}
136+
disabled={!canWrite || form.pular_aquecimento}
137+
className="w-48"
138+
/>
139+
<p className="text-xs text-muted-foreground">
140+
A conexão pode ser nova sem que o número seja. O aquecimento conta a idade do
141+
NÚMERO — se você deixar em branco, ele é tratado como recém-criado e começa
142+
liberando pouco por dia.
143+
</p>
144+
145+
<div className="mt-1 flex items-center gap-2">
146+
<Switch
147+
id="pular-aquecimento"
148+
checked={form.pular_aquecimento}
149+
onCheckedChange={(v) => set({ pular_aquecimento: v })}
150+
disabled={!canWrite}
151+
/>
152+
<Label htmlFor="pular-aquecimento" className="font-normal">
153+
Este número já está aquecido — pular o aquecimento
154+
</Label>
155+
</div>
156+
<p className="text-xs text-muted-foreground">
157+
{form.pular_aquecimento
158+
? "Vale só o teto diário abaixo. Use apenas se o número já envia há semanas: pular o aquecimento num número novo é o caminho mais rápido para o bloqueio."
159+
: item.warmup.cap_today === null
160+
? `Número com ${item.warmup.age_days} dia(s) de uso — já formado. Vale só o teto diário abaixo.`
161+
: `Hoje o aquecimento libera ${item.warmup.cap_today} envio(s) — o número tem ${item.warmup.age_days} dia(s) de uso. Enquanto esse número for menor que o teto diário, é ELE que limita, e mexer no teto diário não muda nada.`}
162+
</p>
163+
</fieldset>
164+
113165
<fieldset className="flex flex-col gap-2">
114166
<Label>Janela de envio (horário local)</Label>
115167
<div className="flex items-center gap-2">

hooks/channels/usePacingKnobs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,19 @@ export interface PacingSessionLite {
1313
daily_message_limit: number | null;
1414
}
1515

16+
/** Estado do aquecimento, já calculado no servidor (a tela não recalcula a regra). */
17+
export interface PacingWarmupView {
18+
number_activated_at: string | null;
19+
age_days: number;
20+
skipped: boolean;
21+
/** Teto de HOJE pelo aquecimento; null = sem teto (número formado ou aquecimento pulado). */
22+
cap_today: number | null;
23+
}
24+
1625
export interface PacingKnobsItem {
1726
channel_session: PacingSessionLite;
1827
effective: PacingKnobs;
28+
warmup: PacingWarmupView;
1929
overrides: ChannelKnobsRow | null;
2030
defaults: PacingKnobs;
2131
bounds: {

lib/agent-engine/pacing/engine.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,12 @@ export function decidePacing(input: PacingInput): PacingDecision {
112112
* Degrau vigente para a idade (degraus ordenados por minAgeDays crescente).
113113
* Falha FECHADO: idade aquém do primeiro degrau usa o cap do PRIMEIRO degrau
114114
* (o mais conservador) — configuração com furo nunca vira "sem cap".
115+
*
116+
* Exportada para a TELA poder dizer ao operador qual é o teto de hoje. A regra
117+
* tem de ser esta mesma função: uma segunda cópia na UI é a receita para a tela
118+
* prometer um número e o motor aplicar outro.
115119
*/
116-
function warmupCapFor(ageDays: number, steps: WarmupStep[]): number | null {
120+
export function warmupCapFor(ageDays: number, steps: WarmupStep[]): number | null {
117121
let cap: number | null = steps[0]?.cap ?? null;
118122
for (const step of steps) {
119123
if (ageDays >= step.minAgeDays) cap = step.cap;

lib/ai/pacing-knobs.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
PACING_DEFAULTS,
1313
type PacingKnobs,
1414
} from "@/lib/agent-engine/pacing/defaults";
15+
import { warmupCapFor } from "@/lib/agent-engine/pacing/engine";
1516
import { parseWarmupCaps } from "@/lib/agent-engine/pacing/store";
1617

1718
function isValidTimezone(tz: string): boolean {
@@ -50,6 +51,28 @@ export const pacingKnobsUpdateSchema = z
5051
.min(DAILY_LIMIT_BOUNDS.min)
5152
.max(DAILY_LIMIT_BOUNDS.max)
5253
.optional(),
54+
/**
55+
* Desde quando o NÚMERO é usado — não desde quando esta conexão existe.
56+
*
57+
* O warm-up mede idade, e a idade nascia do instante em que o número era
58+
* pareado aqui: reconectar um número usado há meses o rebaixava a
59+
* recém-nascido (teto de 20 envios/dia) sem nenhum caminho para corrigir.
60+
* Data futura é recusada — ela ADIANTARIA o aquecimento, que é o oposto de
61+
* proteger.
62+
*/
63+
number_activated_at: z
64+
.string()
65+
.datetime({ offset: true })
66+
.refine((iso) => new Date(iso).getTime() <= Date.now(), "a data não pode estar no futuro")
67+
.nullable()
68+
.optional(),
69+
/**
70+
* "Este número já está aquecido" — pula os degraus por completo, gravando um
71+
* único degrau sem teto. Existe ao lado da data porque são perguntas
72+
* diferentes: a data é um FATO que o dono conhece; isto é uma DECISÃO dele,
73+
* assumindo o risco de banimento de um número que talvez não esteja pronto.
74+
*/
75+
skip_warmup: z.boolean().optional(),
5376
})
5477
.strict();
5578

@@ -89,12 +112,46 @@ export function effectiveKnobs(row: ChannelKnobsRow | null): PacingKnobs {
89112
};
90113
}
91114

92-
/** Payload de GET para a tela: efetivo + o que é override + limites de edição. */
93-
export function knobsView(row: ChannelKnobsRow | null) {
115+
/** Forma gravada em `warmup_daily_caps` quando o dono declara o número já aquecido. */
116+
export const WARMUP_PULADO = [{ minAgeDays: 0, cap: null }] as const;
117+
118+
/** A configuração de warm-up desta conexão é a de "já aquecido"? */
119+
export function warmupEstaPulado(row: ChannelKnobsRow | null): boolean {
120+
const caps = parseWarmupCaps(row?.warmup_daily_caps);
121+
return caps?.length === 1 && caps[0]?.minAgeDays === 0 && caps[0]?.cap === null;
122+
}
123+
124+
/** Dias completos desde a ativação do número. Sem data = 0 (o motor é conservador). */
125+
export function idadeEmDias(row: ChannelKnobsRow | null, agora: Date = new Date()): number {
126+
const iso = row?.number_activated_at;
127+
if (!iso) return 0;
128+
const ms = agora.getTime() - new Date(iso).getTime();
129+
return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : 0;
130+
}
131+
132+
/**
133+
* Payload de GET para a tela: efetivo + o que é override + limites de edição.
134+
*
135+
* `warmup` vai junto porque a tela precisava explicar um veto que ela não sabia
136+
* calcular: o operador lia "número em aquecimento, limite atingido", subia o teto
137+
* diário — o único campo que a tela oferecia — e nada mudava, porque quem barrava
138+
* era o teto por IDADE. Agora o número que decide aparece ao lado do que o
139+
* decide.
140+
*/
141+
export function knobsView(row: ChannelKnobsRow | null, agora: Date = new Date()) {
142+
const efetivo = effectiveKnobs(row);
143+
const dias = idadeEmDias(row, agora);
94144
return {
95-
effective: effectiveKnobs(row),
145+
effective: efetivo,
96146
overrides: row,
97147
defaults: PACING_DEFAULTS,
98148
bounds: { ...KNOB_BOUNDS, daily_limit: DAILY_LIMIT_BOUNDS },
149+
warmup: {
150+
number_activated_at: row?.number_activated_at ?? null,
151+
age_days: dias,
152+
skipped: warmupEstaPulado(row),
153+
/** Teto de HOJE pelo aquecimento. null = sem teto (formado ou pulado). */
154+
cap_today: warmupCapFor(dias, efetivo.warmupDailyCaps),
155+
},
99156
};
100157
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* O aquecimento mede a idade do NÚMERO, e essa idade nascia do instante em que o
3+
* número era pareado. Reconectar um número usado há meses o rebaixava a
4+
* recém-nascido — teto de 20 envios/dia — e não havia caminho nenhum para
5+
* corrigir: o contrato da API era `.strict()` e não aceitava nem a data de
6+
* ativação nem os degraus.
7+
*
8+
* Pior: a tela oferecia o teto DIÁRIO, que é outro gate. O operador subia o teto
9+
* para 1000, nada mudava, e a mensagem seguia falando de aquecimento.
10+
*/
11+
import { describe, expect, it } from "vitest";
12+
13+
import {
14+
WARMUP_PULADO,
15+
idadeEmDias,
16+
knobsView,
17+
pacingKnobsUpdateSchema,
18+
warmupEstaPulado,
19+
type ChannelKnobsRow,
20+
} from "@/lib/ai/pacing-knobs";
21+
22+
const SESSAO = "11111111-1111-4111-8111-111111111111";
23+
const VAZIO: ChannelKnobsRow = {
24+
throttle_ms: null,
25+
jitter_max_ms: null,
26+
window_start_hour: null,
27+
window_end_hour: null,
28+
allow_sunday: null,
29+
timezone: null,
30+
warmup_daily_caps: null,
31+
number_activated_at: null,
32+
};
33+
34+
describe("contrato da API — os campos que faltavam", () => {
35+
it("aceita a data de ativação do número", () => {
36+
const r = pacingKnobsUpdateSchema.safeParse({
37+
channel_session_id: SESSAO,
38+
number_activated_at: "2026-04-29T02:15:52.000Z",
39+
});
40+
expect(r.success).toBe(true);
41+
});
42+
43+
it("recusa data no FUTURO — adiantar o aquecimento é o oposto de proteger", () => {
44+
const amanha = new Date(Date.now() + 86_400_000).toISOString();
45+
const r = pacingKnobsUpdateSchema.safeParse({
46+
channel_session_id: SESSAO,
47+
number_activated_at: amanha,
48+
});
49+
expect(r.success).toBe(false);
50+
});
51+
52+
it("aceita null — volta a tratar o número como recém-criado", () => {
53+
const r = pacingKnobsUpdateSchema.safeParse({
54+
channel_session_id: SESSAO,
55+
number_activated_at: null,
56+
});
57+
expect(r.success).toBe(true);
58+
});
59+
60+
it("aceita a decisão de pular o aquecimento", () => {
61+
const r = pacingKnobsUpdateSchema.safeParse({ channel_session_id: SESSAO, skip_warmup: true });
62+
expect(r.success).toBe(true);
63+
});
64+
65+
it("continua estrito: campo desconhecido é recusado", () => {
66+
const r = pacingKnobsUpdateSchema.safeParse({ channel_session_id: SESSAO, inventado: 1 });
67+
expect(r.success).toBe(false);
68+
});
69+
});
70+
71+
describe("idade do número", () => {
72+
it("conta dias completos desde a ativação", () => {
73+
const row = { ...VAZIO, number_activated_at: "2026-04-29T02:00:00.000Z" };
74+
expect(idadeEmDias(row, new Date("2026-08-06T02:00:00.000Z"))).toBe(99);
75+
});
76+
77+
it("sem data = idade 0 — o motor falha fechado, e a view não inventa idade", () => {
78+
expect(idadeEmDias(VAZIO)).toBe(0);
79+
});
80+
81+
it("data no futuro não vira idade negativa", () => {
82+
const row = { ...VAZIO, number_activated_at: "2030-01-01T00:00:00.000Z" };
83+
expect(idadeEmDias(row, new Date("2026-08-06T00:00:00.000Z"))).toBe(0);
84+
});
85+
});
86+
87+
describe("knobsView.warmup — o número que a tela precisava mostrar", () => {
88+
it("número recém-pareado: teto de hoje é o do primeiro degrau, NÃO o teto diário", () => {
89+
const hoje = new Date("2026-08-06T20:00:00.000Z");
90+
const row = { ...VAZIO, number_activated_at: "2026-08-06T19:00:00.000Z" };
91+
const v = knobsView(row, hoje);
92+
expect(v.warmup.age_days).toBe(0);
93+
expect(v.warmup.cap_today).toBe(20);
94+
expect(v.warmup.skipped).toBe(false);
95+
});
96+
97+
it("número com mais de 31 dias: sem teto de aquecimento", () => {
98+
const v = knobsView(
99+
{ ...VAZIO, number_activated_at: "2026-04-29T02:00:00.000Z" },
100+
new Date("2026-08-06T02:00:00.000Z"),
101+
);
102+
expect(v.warmup.cap_today).toBeNull();
103+
});
104+
105+
it("aquecimento pulado: sem teto mesmo com o número nascido hoje", () => {
106+
const hoje = new Date("2026-08-06T20:00:00.000Z");
107+
const row = {
108+
...VAZIO,
109+
number_activated_at: "2026-08-06T19:00:00.000Z",
110+
warmup_daily_caps: [...WARMUP_PULADO],
111+
};
112+
const v = knobsView(row, hoje);
113+
expect(v.warmup.age_days).toBe(0);
114+
expect(v.warmup.skipped).toBe(true);
115+
expect(v.warmup.cap_today).toBeNull();
116+
});
117+
});
118+
119+
describe("warmupEstaPulado", () => {
120+
it("reconhece a forma gravada por skip_warmup", () => {
121+
expect(warmupEstaPulado({ ...VAZIO, warmup_daily_caps: [...WARMUP_PULADO] })).toBe(true);
122+
});
123+
124+
it("os degraus padrão NÃO são 'pulado'", () => {
125+
expect(warmupEstaPulado(VAZIO)).toBe(false);
126+
});
127+
128+
it("degraus customizados com teto não contam como pulado", () => {
129+
const row = { ...VAZIO, warmup_daily_caps: [{ minAgeDays: 0, cap: 500 }] };
130+
expect(warmupEstaPulado(row)).toBe(false);
131+
});
132+
});

0 commit comments

Comments
 (0)