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
262 lines
7.3 KiB
TypeScript
262 lines
7.3 KiB
TypeScript
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.
|
||
`;
|
||
}
|
||
|