Skip to content

Commit 9ccc445

Browse files
Implementou guardrails do CRM
X-Lovable-Edit-ID: edt-64a10f98-8aa0-4bc2-aa0d-40663332a2ac Co-authored-by: criptogus <128640021+criptogus@users.noreply.github.com>
2 parents 281d1a2 + d2775f7 commit 9ccc445

6 files changed

Lines changed: 264 additions & 29 deletions

File tree

src/components/crm/EffectivenessPanel.tsx

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,14 @@ export function EffectivenessPanel() {
5959
toast.success(
6060
r.job === "score"
6161
? `Scored ${r.checked} messages · ${r.converted} converted`
62-
: `Tuner: ${r.paused} paused, ${r.drafted} drafted`,
62+
: `Tuner: ${r.paused} paused, ${r.activated} published, ${r.blocked} blocked by guardrails`,
6363
);
6464
q.refetch();
6565
},
6666
onError: (e: any) => toast.error(e?.message ?? "Job failed"),
6767
});
6868

69+
6970
const data = q.data;
7071
const maxHour = Math.max(1, ...(data?.hours ?? []).map((h) => h.sent));
7172

@@ -74,16 +75,19 @@ export function EffectivenessPanel() {
7475
<Card>
7576
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 pb-3">
7677
<div>
77-
<CardTitle className="text-base">Learning loop</CardTitle>
78+
<CardTitle className="text-base">Autonomous learning loop</CardTitle>
7879
<p className="mt-1 text-xs text-muted-foreground">
79-
Picks the copy variant and the trigger with the best measured outcome, and only changes
80-
anything after {data?.min_samples ?? 20} sends per variant. Cadence caps never change.
80+
Runs without approvals: it picks the copy variant, the trigger and the send hour with the
81+
best measured outcome, and publishes its own replacement copy once the guardrails below
82+
pass. It only changes anything after {data?.min_samples ?? 20} sends per variant, and
83+
cadence caps never change.
8184
</p>
8285
</div>
8386
<div className="flex flex-wrap gap-2">
8487
<Badge variant={data?.learning_enabled ? "default" : "secondary"}>
85-
{data?.learning_enabled ? "Learning on" : "Learning paused"}
88+
{data?.learning_enabled ? "Autonomous" : "Learning paused"}
8689
</Badge>
90+
8791
<Button
8892
size="sm"
8993
variant="outline"
@@ -262,14 +266,39 @@ export function EffectivenessPanel() {
262266

263267
<Card>
264268
<CardHeader className="pb-3">
265-
<CardTitle className="text-base">Drafted variants waiting for approval</CardTitle>
269+
<CardTitle className="text-base">Guardrails</CardTitle>
270+
<p className="mt-1 text-xs text-muted-foreground">
271+
The hard limits the CRM can never cross on its own. Anything that breaks one of these is
272+
quarantined instead of being sent.
273+
</p>
274+
</CardHeader>
275+
<CardContent>
276+
<ul className="grid gap-2 sm:grid-cols-2">
277+
{(data?.guardrails ?? []).map((g, i) => (
278+
<li key={i} className="flex items-start gap-2 rounded border p-2 text-sm">
279+
<Badge variant={g.allowed ? "default" : "secondary"} className="mt-0.5 shrink-0">
280+
{g.allowed ? "Can" : "Cannot"}
281+
</Badge>
282+
<span className={g.allowed ? "" : "text-muted-foreground"}>{g.rule}</span>
283+
</li>
284+
))}
285+
</ul>
286+
</CardContent>
287+
</Card>
288+
289+
<Card>
290+
<CardHeader className="pb-3">
291+
<CardTitle className="text-base">Quarantined copy</CardTitle>
266292
<p className="mt-1 text-xs text-muted-foreground">
267-
Written by AI when a framing loses. Nothing is sent before you approve it.
293+
Self-written variants that failed a guardrail. They are never sent — publishing one is a
294+
manual override.
268295
</p>
269296
</CardHeader>
270297
<CardContent>
271298
{(data?.pending ?? []).length === 0 ? (
272-
<p className="text-sm text-muted-foreground">No drafts pending.</p>
299+
<p className="text-sm text-muted-foreground">
300+
Nothing quarantined — every self-written variant passed the guardrails.
301+
</p>
273302
) : (
274303
<ul className="space-y-3">
275304
{(data?.pending ?? []).map((p) => (
@@ -281,32 +310,35 @@ export function EffectivenessPanel() {
281310
<div className="flex gap-2">
282311
<Button
283312
size="sm"
313+
variant="outline"
284314
disabled={reviewMutation.isPending}
285315
onClick={() => reviewMutation.mutate({ id: p.id, decision: "approve" })}
286316
>
287-
Approve
317+
Publish anyway
288318
</Button>
289319
<Button
290320
size="sm"
291321
variant="outline"
292322
disabled={reviewMutation.isPending}
293323
onClick={() => reviewMutation.mutate({ id: p.id, decision: "reject" })}
294324
>
295-
Reject
325+
Discard
296326
</Button>
297327
</div>
298328
</div>
299329
<div className="mt-2 space-y-1">
300330
<div className="font-semibold">{p.subject}</div>
301331
<div>{p.heading}</div>
302332
<p className="text-muted-foreground">{p.intro}</p>
333+
{p.notes ? <p className="text-xs text-destructive">{p.notes}</p> : null}
303334
</div>
304335
</li>
305336
))}
306337
</ul>
307338
)}
308339
</CardContent>
309340
</Card>
341+
310342
</div>
311343
);
312344
}

src/lib/crm/crm.functions.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,10 @@ export type CrmEffectiveness = {
405405
subject: string | null;
406406
heading: string | null;
407407
intro: string | null;
408+
notes: string | null;
408409
created_at: string;
409410
}>;
411+
guardrails: Array<{ allowed: boolean; rule: string }>;
410412
changelog: Array<{
411413
action: string;
412414
trigger: string | null;
@@ -416,10 +418,13 @@ export type CrmEffectiveness = {
416418
}>;
417419
};
418420

421+
419422
export const getCrmEffectiveness = createServerFn({ method: "POST" })
420423
.middleware([requireAdmin])
421424
.handler(async (): Promise<CrmEffectiveness> => {
422425
const { loadLearningState } = await import("@/lib/crm/learning.server");
426+
const { AUTONOMY_RULES } = await import("@/lib/crm/guardrails");
427+
423428
const { OUTCOMES, VARIANTS, armKey, estimatedRate, EMPTY_ARM } = await import(
424429
"@/lib/crm/learning"
425430
);
@@ -491,11 +496,14 @@ export const getCrmEffectiveness = createServerFn({ method: "POST" })
491496

492497
const { data: pending } = await admin
493498
.from("crm_copy_variants")
494-
.select("id, trigger, variant, label, subject_override, heading_override, intro_override, created_at")
495-
.eq("status", "pending")
499+
.select(
500+
"id, trigger, variant, label, subject_override, heading_override, intro_override, notes, created_at",
501+
)
502+
.in("status", ["pending", "quarantined"])
496503
.order("created_at", { ascending: false })
497504
.limit(20);
498505

506+
499507
const { data: changelog } = await admin
500508
.from("crm_tuning_log")
501509
.select("action, trigger, variant, reason, created_at")
@@ -518,8 +526,11 @@ export const getCrmEffectiveness = createServerFn({ method: "POST" })
518526
subject: p.subject_override,
519527
heading: p.heading_override,
520528
intro: p.intro_override,
529+
notes: p.notes ?? null,
521530
created_at: p.created_at,
522531
})),
532+
guardrails: AUTONOMY_RULES,
533+
523534
changelog: ((changelog ?? []) as any[]).map((c) => ({
524535
action: c.action,
525536
trigger: c.trigger,
@@ -585,5 +596,13 @@ export const runCrmLearningNow = createServerFn({ method: "POST" })
585596
return { job: "score" as const, ...r };
586597
}
587598
const r = await runTuner({ dryRun: data.dryRun });
588-
return { job: "tune" as const, paused: r.paused.length, drafted: r.drafted.length, leaders: r.leaders };
599+
return {
600+
job: "tune" as const,
601+
paused: r.paused.length,
602+
drafted: r.drafted.length,
603+
activated: r.activated.length,
604+
blocked: r.blocked.length,
605+
leaders: r.leaders,
606+
};
607+
589608
});

src/lib/crm/guardrails.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* CRM autonomy guardrails.
3+
*
4+
* The CRM runs without human approval. These are the hard limits it may never
5+
* cross, plus the deterministic checks every self-written copy variant has to
6+
* pass before it can go live. Anything that fails is quarantined and logged
7+
* instead of being sent.
8+
*/
9+
10+
export const GUARDRAILS = {
11+
/** Cadence limits — the learning loop can never widen these. */
12+
maxEmailsPer7Days: 2,
13+
minHoursBetweenEmails: 48,
14+
/** Copy shape limits. */
15+
maxSubjectChars: 70,
16+
maxHeadingChars: 60,
17+
maxIntroChars: 200,
18+
/** How much the loop may change on its own in a single run. */
19+
maxAutoPausesPerRun: 3,
20+
maxAutoActivationsPerRun: 2,
21+
/** A trigger always keeps at least one and at most this many live variants. */
22+
minActiveVariantsPerTrigger: 1,
23+
maxActiveVariantsPerTrigger: 4,
24+
/** No self-written variant goes live for a trigger with too little evidence. */
25+
minSentBeforeAutoActivation: 20,
26+
} as const;
27+
28+
/** Plain-language list of what the CRM may and may not do on its own. */
29+
export const AUTONOMY_RULES: Array<{ allowed: boolean; rule: string }> = [
30+
{ allowed: true, rule: "Pick the copy variant and the send hour with the best measured outcome." },
31+
{ allowed: true, rule: "Pause a variant once it is statistically behind the leader." },
32+
{ allowed: true, rule: "Write a replacement variant and publish it when it passes every copy check." },
33+
{ allowed: true, rule: "Rank triggers by measured business value and back off for unengaged customers." },
34+
{ allowed: false, rule: "Send more than 2 emails per customer per 7 days, or closer than 48 hours apart." },
35+
{ allowed: false, rule: "Email a suppressed, unsubscribed or bounced address." },
36+
{ allowed: false, rule: "Publish copy with invented metrics, guarantees, urgency pressure or discounts." },
37+
{ allowed: false, rule: "Publish copy in a language other than English, or with emojis." },
38+
{ allowed: false, rule: "Leave a trigger without a working variant, or run more than 4 live variants." },
39+
{ allowed: false, rule: "Change cadence caps, suppression rules or the unsubscribe footer." },
40+
];
41+
42+
/** Claims and pressure tactics the CRM is not allowed to make on its own. */
43+
const BANNED_PATTERNS: Array<{ re: RegExp; reason: string }> = [
44+
{ re: /\b(guarantee[ds]?|guaranteed results|risk[- ]free)\b/i, reason: "promises a guaranteed result" },
45+
{ re: /\b(\d{2,3}\s?%\s?(more|less|faster|cheaper|increase|boost))/i, reason: "invents a performance metric" },
46+
{ re: /\b(\d+x)\s+(faster|better|more|cheaper)\b/i, reason: "invents a multiplier claim" },
47+
{ re: /\b(act now|last chance|final warning|hurry|expires? (today|tonight)|only \d+ (spots|hours) left)\b/i, reason: "uses urgency pressure" },
48+
{ re: /\b(free money|no strings|cash back|\d+\s?% off|discount code|coupon)\b/i, reason: "offers pricing terms the CRM cannot authorise" },
49+
{ re: /\b(refund|chargeback|invoice|credit card|password|api key|token)\b/i, reason: "touches billing or credential topics" },
50+
{ re: /\b(you must|you have to|failure to (act|respond)|legal action)\b/i, reason: "uses coercive language" },
51+
{ re: /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u, reason: "contains emojis" },
52+
{ re: /\b(garantia|clique aqui|voc[êe]|gr[áa]tis|obrigado|aqui est[áa])\b/i, reason: "is not written in English" },
53+
{ re: /\{\{|\}\}|\[insert|TODO|lorem ipsum/i, reason: "contains unfilled placeholders" },
54+
];
55+
56+
export type CopyCandidate = {
57+
label: string;
58+
subject: string;
59+
heading: string;
60+
intro: string;
61+
};
62+
63+
export type GuardrailVerdict = { ok: boolean; violations: string[] };
64+
65+
/** Deterministic gate for self-written copy. No model in the loop. */
66+
export function checkCopy(candidate: CopyCandidate): GuardrailVerdict {
67+
const violations: string[] = [];
68+
const { label, subject, heading, intro } = candidate;
69+
70+
if (!label.trim() || !subject.trim() || !heading.trim() || !intro.trim())
71+
violations.push("is missing a label, subject, heading or intro");
72+
if (subject.length > GUARDRAILS.maxSubjectChars)
73+
violations.push(`subject is longer than ${GUARDRAILS.maxSubjectChars} characters`);
74+
if (heading.length > GUARDRAILS.maxHeadingChars)
75+
violations.push(`heading is longer than ${GUARDRAILS.maxHeadingChars} characters`);
76+
if (intro.length > GUARDRAILS.maxIntroChars)
77+
violations.push(`intro is longer than ${GUARDRAILS.maxIntroChars} characters`);
78+
if (subject === subject.toUpperCase() && subject.replace(/[^A-Z]/g, "").length > 6)
79+
violations.push("subject shouts in all caps");
80+
if ((subject.match(/!/g) ?? []).length > 0) violations.push("subject uses exclamation marks");
81+
82+
const blob = `${label}\n${subject}\n${heading}\n${intro}`;
83+
for (const { re, reason } of BANNED_PATTERNS) if (re.test(blob)) violations.push(reason);
84+
85+
// Non-ASCII beyond normal punctuation is a strong signal of another language.
86+
if (/[À-ÿ]/.test(blob)) violations.push("contains non-English characters");
87+
88+
return { ok: violations.length === 0, violations };
89+
}
90+
91+
/** Can this trigger accept one more live variant right now? */
92+
export function canActivate(activeCount: number, leaderSent: number): GuardrailVerdict {
93+
const violations: string[] = [];
94+
if (activeCount >= GUARDRAILS.maxActiveVariantsPerTrigger)
95+
violations.push(`trigger already runs ${GUARDRAILS.maxActiveVariantsPerTrigger} live variants`);
96+
if (leaderSent < GUARDRAILS.minSentBeforeAutoActivation)
97+
violations.push(
98+
`only ${leaderSent} sends measured, ${GUARDRAILS.minSentBeforeAutoActivation} required before publishing new copy`,
99+
);
100+
return { ok: violations.length === 0, violations };
101+
}
102+
103+
/** Can this variant be paused without leaving the trigger empty or over-churning? */
104+
export function canPause(activeCount: number, pausesThisRun: number): GuardrailVerdict {
105+
const violations: string[] = [];
106+
if (activeCount - 1 < GUARDRAILS.minActiveVariantsPerTrigger)
107+
violations.push("pausing it would leave the trigger without a working variant");
108+
if (pausesThisRun >= GUARDRAILS.maxAutoPausesPerRun)
109+
violations.push(`already paused ${GUARDRAILS.maxAutoPausesPerRun} variants in this run`);
110+
return { ok: violations.length === 0, violations };
111+
}

0 commit comments

Comments
 (0)