Files
pagodera/lib/ai/generate-item-structure.ts
Evandro Fonseca Junior 6adc8d96e8 refactor: reorganize hooks and add safety checks
fix(api): add validation for listing and seller in bid route
feat(telemetry): add userId to MarketTelemetry
style(globe): add null check for canvas element
chore: add lint:fix script to package.json
2025-11-11 03:20:01 -03:00

262 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
buildGeminiParts,
buildItemPrompt,
type InlineImageData,
} from "@/lib/ai/item-prompt";
import { listCategoryTemplates } from "@/lib/items/sqlite-manager";
import { secureLogger } from "@/lib/secure-logger";
interface GenerateItemStructureInput {
name: string;
description: string;
scope: "global" | "server";
server?: string | null;
categories: string[];
servers: string[];
images?: InlineImageData[];
}
interface GeminiResponse {
structure: {
table: string;
columns: Record<string, unknown>;
};
raw: unknown;
usage?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
model?: string;
}
const FALLBACK_STRUCTURE = {
table: "items_all",
columns: {
name: "Final Fate",
description:
"Usado no slot de tocha. Bônus em leech, velocidade e proteção física.",
attributes: {
shielding: 20,
death_magic: 120,
mana_leech: { value: 30, chance: 100 },
life_leech: { value: 25, chance: 100 },
critical_hit: { damage: 25, chance: 75 },
speed: 1000,
protection_physical: 40,
damage_reflection: 80,
},
},
};
export async function generateItemStructure(
input: GenerateItemStructureInput,
): Promise<GeminiResponse> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
secureLogger.warn(
"⚠️ GEMINI_API_KEY ausente – utilizando fallback estático.",
);
return {
structure: FALLBACK_STRUCTURE,
raw: FALLBACK_STRUCTURE,
};
}
const url =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
// Coleta estrutura do SQLite (somente nomes de tabelas, colunas e tipos) e injeta no prompt.
let sqliteSchemaText = "- Nenhuma tabela encontrada";
try {
const templates = listCategoryTemplates();
if (templates.length > 0) {
const lines: string[] = ["Estruturas atuais no SQLite:"];
templates.forEach((tpl) => {
lines.push(`- Tabela: ${tpl.name}`);
tpl.columns.forEach((col) => {
lines.push(` - ${col.name}: ${col.type}`);
});
});
sqliteSchemaText = lines.join("\n");
}
} catch (_e) {
sqliteSchemaText = "[Falha ao listar SQLite]";
}
const extra = [
// Instruções específicas para exigência de saída fixa
"Inclua SEMPRE o campo fixo 'schemaSuggestion' com a melhor tabela existente ou a sugestão de nova, e a lista de colunas com tipos (TEXT|INTEGER|REAL|BOOLEAN).",
"Nunca inclua dados reais do SQLite; use somente a estrutura listada.",
// Reforços de consistência e proibições
"Para escopo global, a tabela deve ser 'items_all'. Para escopo de servidor, use 'items_<slug_do_servidor>' com slug minúsculo, '_' como separador, sem caracteres não alfanuméricos.",
"Servidores em 'columns.servers' DEVEM pertencer à lista fornecida no contexto; prefira um array de strings.",
"Em 'columns.metadata', insira apenas atributos descritivos. É proibido incluir campos de valor/preço/mercado: price, value, buy, sell, currency, npc_price, marketPrice, minPrice, maxPrice, avgPrice.",
"Garanta que 'columns.description' tenha no máximo 100 caracteres; se maior, recorte exatamente os primeiros 100 sem sufixos.",
sqliteSchemaText,
].join("\n");
const prompt = await buildItemPrompt(
{
name: input.name,
description: input.description,
scope: input.scope,
server: input.server,
categories: input.categories,
servers: input.servers,
},
[
input.images && input.images.length > 0
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
: null,
extra,
]
.filter(Boolean)
.join("\n"),
);
const parts = await buildGeminiParts(prompt, input.images);
async function callOnce(): Promise<any> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { "x-goog-api-key": apiKey } : {}),
},
body: JSON.stringify({
contents: [
{
role: "user",
parts,
},
],
generationConfig: {
temperature: 0,
topP: 0.1,
},
}),
});
const bodyText = await response.text();
const bodyJson = (() => {
try {
return JSON.parse(bodyText);
} catch {
return { raw: bodyText };
}
})();
if (!response.ok) {
const status = response.status;
const msg =
bodyJson?.error?.message || `Gemini retornou status ${status}`;
const err = new Error(msg);
(err as any).status = status;
(err as any).response = bodyJson;
throw err;
}
return bodyJson;
}
try {
// Retry simples para 429 (rate limit): até 2 tentativas com backoff.
let attempts = 0;
let data: any;
while (true) {
try {
data = await callOnce();
break;
} catch (e) {
const status = (e as any)?.status;
attempts++;
if (status === 429 && attempts <= 2) {
const delayMs = 500 * attempts;
await new Promise((r) => setTimeout(r, delayMs));
continue;
}
throw e;
}
}
const text =
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
data?.candidates?.[0]?.output_text;
if (!text) {
throw new Error("Resposta do Gemini vazia ou inválida.");
}
const cleaned = text
.replace(/```json/gi, "")
.replace(/```/g, "")
.trim();
const parsed = JSON.parse(cleaned);
return {
structure: parsed,
raw: data,
usage: data?.usageMetadata,
model: "gemini-2.5-flash",
};
} catch (error) {
secureLogger.error("❌ Falha ao gerar estrutura via Gemini", {
error: error instanceof Error ? error.message : "Erro desconhecido",
status: (error as any)?.status,
response: (error as any)?.response,
});
return {
structure: FALLBACK_STRUCTURE,
raw: FALLBACK_STRUCTURE,
usage: undefined,
model: "gemini-2.5-flash",
};
}
}
function _buildPrompt({
name,
description,
scope,
server,
}: GenerateItemStructureInput) {
return `
Você é um assistente que prepara carga útil em JSON para inserção de itens em banco de dados.
Requisitos obrigatórios:
- Responda SOMENTE com JSON válido, sem comentários, sem markdown, sem texto adicional.
- Formato esperado:
{
"table": "nome_da_tabela",
"columns": {
"...": "..."
}
}
- Nunca inclua explicações fora do JSON.
Contexto do item:
- Nome: ${name}
- Escopo: ${scope === "server" ? `específico do servidor ${server ?? "não informado"}` : "global"}
- Descrição original (mantenha estrutura e extrair atributos quando possível):
"""
${description}
"""
Instruções:
- Escolha uma tabela coerente com o escopo (ex.: "items_all" para global ou "items_${(
server ?? "custom"
)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")}" para servidor).
- Dentro de "columns", inicie com "name" e "description".
- Modele atributos numéricos em objetos ou subtabelas conforme necessário (ex.: {"attributes": {...}}).
- Quando não tiver certeza de um campo, deixe nulo.
- Nunca invente dados além do que estiver na descrição.
- Garante que o JSON seja minuciosamente válido.
`;
}