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:
1 parent
dc169f9eba
commit
d3704602fd
22 files changed
+1317
-95
No files matched your search
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user