feat(items): add server field and improve item management

refactor(ai): enhance item structure generation with schema validation
feat(ai): add token usage tracking and cost calculation
fix(ui): improve item card number formatting with locale support
docs(items): add documentation for item value separation
test(items): add tests for value field validation
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-11 03:04:49 -03:00
1 parent dc169f9eba
commit d3704602fd
22 files changed
+1317 -95

No files matched your search

+92 -9
View File
@@ -1,5 +1,6 @@
import { secureLogger } from "@/lib/secure-logger";
import { buildGeminiParts, buildItemPrompt, InlineImageData } from "@/lib/ai/item-prompt";
import { listCategoryTemplates } from "@/lib/items/sqlite-manager";
interface GenerateItemStructureInput {
name: string;
@@ -17,6 +18,12 @@ interface GeminiResponse {
columns: Record<string, unknown>;
};
raw: unknown;
usage?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
model?: string;
}
const FALLBACK_STRUCTURE = {
@@ -24,7 +31,7 @@ const FALLBACK_STRUCTURE = {
columns: {
name: "Final Fate",
description:
"Item lendário utilizado no slot de tocha. Oferece bônus massivos em skills mágicas, velocidade e efeitos de leech.",
"Usado no slot de tocha. Bônus em leech, velocidade e proteção física.",
attributes: {
shielding: 20,
death_magic: 120,
@@ -52,8 +59,37 @@ export async function generateItemStructure(
}
const url =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
apiKey;
"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(
{
@@ -64,18 +100,24 @@ export async function generateItemStructure(
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."
: undefined,
[
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);
try {
async function callOnce(): Promise<any> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify({
contents: [
@@ -91,11 +133,46 @@ export async function generateItemStructure(
}),
});
const bodyText = await response.text();
const bodyJson = (() => {
try {
return JSON.parse(bodyText);
} catch {
return { raw: bodyText };
}
})();
if (!response.ok) {
throw new Error(`Gemini retornou status ${response.status}`);
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;
}
const data = await response.json();
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;
@@ -114,15 +191,21 @@ export async function generateItemStructure(
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",
};
}
}