Applied default skills hardening

X-Lovable-Edit-ID: edt-6d6c0346-b8bc-44ea-a630-43a989a99ad8
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-05-28 21:59:43 +00:00
commit 2782e0d182
8 changed files with 167 additions and 84 deletions

View file

@ -191,11 +191,19 @@ export function SkillCard({ skill }: { skill: Skill }) {
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<SkillStatusBadge status={skill.status} />
{(skill as Skill & { is_default?: boolean }).is_default && (
<span className="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
Padrão
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(skill.updated_at), { addSuffix: true, locale: ptBR })}
</span>
</div>
</div>
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>

View file

@ -76,11 +76,15 @@ export function SkillTestPanel({
const runTest = useMutation({
mutationFn: async (): Promise<TestResult> => {
if (stateless) {
// Modo preview sem persistência: chama AI direto via edge function
const start = Date.now();
const requestBody: Record<string, unknown> = { test_input: input };
if (stateless) {
requestBody.markdown_content = stateless.markdown_content;
} else {
requestBody.skill_version_id = skillVersionId;
}
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
body: { skill_version_id: skillVersionId, test_input: input },
body: requestBody,
});
if (error) {
return {
@ -90,12 +94,6 @@ export function SkillTestPanel({
};
}
return data as TestResult;
}
const { data, error } = await supabase.functions.invoke("test-skill-dry-run", {
body: { skill_version_id: skillVersionId, test_input: input },
});
if (error) throw new Error(error.message);
return data as TestResult;
},
onSuccess: (r) => {
setResult(r);
@ -110,6 +108,7 @@ export function SkillTestPanel({
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">

View file

@ -694,6 +694,7 @@ export type Database = {
current_version_id: string | null
description: string
id: string
is_default: boolean
name: string
status: string
trigger_keywords: string
@ -706,6 +707,7 @@ export type Database = {
current_version_id?: string | null
description: string
id?: string
is_default?: boolean
name: string
status?: string
trigger_keywords: string
@ -718,6 +720,7 @@ export type Database = {
current_version_id?: string | null
description?: string
id?: string
is_default?: boolean
name?: string
status?: string
trigger_keywords?: string

View file

@ -67,10 +67,11 @@ function SkillsPage() {
<div className="flex items-center gap-3 flex-wrap">
{limits.data && limits.data.max_skills != null && (
<span className="text-sm text-muted-foreground px-3 py-1.5 rounded-lg bg-muted">
{limits.data.current_skills_count} de {limits.data.max_skills} skills
{limits.data.current_skills_count} de {limits.data.max_skills} skills personalizadas
</span>
)}
<div className="flex items-center gap-2">
<Switch id="archived" checked={showArchived} onCheckedChange={setShowArchived} />
<Label htmlFor="archived" className="text-sm text-muted-foreground cursor-pointer">

View file

@ -228,7 +228,7 @@ function SkillPreviewPage() {
open={testOpen}
onOpenChange={setTestOpen}
skillName={(formInputs as Record<string, string>).name || "Skill"}
skillVersionId="preview"
skillVersionId=""
triggerKeywords={(formInputs as Record<string, string>).trigger_keywords}
stateless={{ markdown_content: markdown }}
/>

View file

@ -247,9 +247,11 @@ export async function ensureDefaultSkillsForAgent(
description: template.description,
trigger_keywords: template.trigger_keywords,
status: "draft",
is_default: true,
})
.select("id")
.single();
const skill = skillData as { id: string } | null;
if (skillErr || !skill) {

View file

@ -34,7 +34,7 @@ Deno.serve(async (req) => {
}
const userId = userData.user.id;
let body: { skill_version_id?: string; test_input?: string };
let body: { skill_version_id?: string; test_input?: string; markdown_content?: string };
try {
body = await req.json();
} catch {
@ -44,14 +44,38 @@ Deno.serve(async (req) => {
});
}
const { skill_version_id, test_input } = body;
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
const { skill_version_id, test_input, markdown_content } = body;
if (!test_input || test_input.trim().length === 0) {
return new Response(
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
JSON.stringify({ error: "test_input é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
// Stateless mode: markdown_content direto (preview de nova skill, sem persistência)
const isStateless =
!!markdown_content &&
markdown_content.trim().length > 0 &&
(!skill_version_id || skill_version_id === "preview");
let resolvedMarkdown: string | null = null;
let persistedVersionId: string | null = null;
if (isStateless) {
if (markdown_content!.length > 50000) {
return new Response(JSON.stringify({ error: "markdown_content excede 50.000 caracteres" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
resolvedMarkdown = markdown_content!;
} else {
if (!skill_version_id) {
return new Response(
JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
// Verifica ownership: skill_version -> skill -> user_id
const { data: versionRow, error: vErr } = await admin
.from("skill_versions")
@ -72,12 +96,17 @@ Deno.serve(async (req) => {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
resolvedMarkdown = versionRow.markdown_content as string;
persistedVersionId = skill_version_id;
}
// Cria registro running
// Cria registro running apenas no modo persistido
let runId: string | null = null;
if (persistedVersionId) {
const { data: runRow, error: runErr } = await admin
.from("skill_test_runs")
.insert({
skill_version_id,
skill_version_id: persistedVersionId,
user_id: userId,
test_input,
status: "running",
@ -93,10 +122,16 @@ Deno.serve(async (req) => {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
runId = runRow.id;
}
const runId = runRow.id;
const startedAt = Date.now();
const updateRun = async (patch: Record<string, unknown>) => {
if (!runId) return;
await admin.from("skill_test_runs").update(patch).eq("id", runId);
};
try {
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
@ -110,7 +145,7 @@ Deno.serve(async (req) => {
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`,
content: `Definição da skill:\n\`\`\`\n${resolvedMarkdown}\n\`\`\`\n\nInput do usuário: ${test_input}`,
},
],
}),
@ -118,14 +153,11 @@ Deno.serve(async (req) => {
if (aiRes.status === 429) {
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
await updateRun({
status: "error",
error_message: "Muitas requisições. Aguarde 1 minuto.",
duration_ms: duration,
})
.eq("id", runId);
});
return new Response(
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
@ -135,14 +167,11 @@ Deno.serve(async (req) => {
if (!aiRes.ok) {
const txt = await aiRes.text();
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
await updateRun({
status: "error",
error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
duration_ms: duration,
})
.eq("id", runId);
});
return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
@ -153,10 +182,7 @@ Deno.serve(async (req) => {
const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId);
await updateRun({ status: "success", test_output, duration_ms: duration });
return new Response(
JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
@ -165,13 +191,11 @@ Deno.serve(async (req) => {
} catch (e) {
const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido";
await admin
.from("skill_test_runs")
.update({ status: "error", error_message: msg, duration_ms: duration })
.eq("id", runId);
await updateRun({ status: "error", error_message: msg, duration_ms: duration });
return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});

View file

@ -0,0 +1,46 @@
-- Add is_default column to skills and update user_skill_limits view
-- to exclude default skills from custom skill quota
ALTER TABLE public.skills
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
-- Mark known default skills by name (where description matches the template too)
UPDATE public.skills
SET is_default = true
WHERE is_default = false
AND name IN ('Resumo diario', 'Planejamento semanal', 'Preparar reuniao')
AND description IN (
'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.',
'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.',
'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
);
-- Recreate user_skill_limits to exclude default skills from quota count
DROP VIEW IF EXISTS public.user_skill_limits;
CREATE VIEW public.user_skill_limits AS
SELECT
p.id AS user_id,
pl.slug AS plan_slug,
CASE
WHEN pl.slug IS NULL THEN NULL::integer
WHEN pl.slug = 'basic' THEN 5
WHEN pl.slug = 'starter' THEN 15
WHEN pl.slug = 'professional' THEN 50
WHEN pl.slug = 'enterprise' THEN 999999
ELSE NULL::integer
END AS max_skills,
(
SELECT count(*)::integer
FROM public.skills sk
WHERE sk.user_id = p.id
AND sk.status <> 'archived'
AND COALESCE(sk.is_default, false) = false
) AS current_skills_count
FROM public.profiles p
LEFT JOIN public.subscriptions s
ON s.user_id = p.id
AND s.status = ANY (ARRAY['active'::text, 'trialing'::text])
LEFT JOIN public.plans pl ON pl.id = s.plan_id;
GRANT SELECT ON public.user_skill_limits TO authenticated;
GRANT SELECT ON public.user_skill_limits TO service_role;