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",
};
}
}
+34 -10
View File
@@ -56,7 +56,7 @@ Front-end fields (AdminItemForm):
- skill: string opcional
- servers: lista de servidores (separados por vírgula)
- tags: lista de tags (texto)
- description: texto longo
- description: texto (máximo 100 caracteres)
- dynamic metadata: campos variáveis definidos por categoria (servidores, atributos, etc)
`.trim();
@@ -79,7 +79,9 @@ Você é um assistente que prepara payloads JSON para cadastro de itens no banco
INSTRUÇÕES IMPORTANTES:
- Responda SOMENTE com JSON válido. Nenhum texto fora do JSON.
- Se não tiver certeza, retorne null nos campos desconhecidos.
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
- Você receberá somente a estrutura das tabelas SQLite (nomes, colunas e tipos). Use esse esquema para escolher uma tabela existente adequada ou sugerir uma nova. Nunca inclua dados reais.
- Campo "description": limite máximo de 100 caracteres. Se a descrição original exceder 100 caracteres, retorne apenas os primeiros 100 caracteres sem acrescentar sufixos (sem "..."), e nunca gere texto além desse limite.
Contexto fornecido pelo usuário:
- Nome: ${context.name}
@@ -103,19 +105,41 @@ Formato obrigatório da resposta:
{
"table": "nome_da_tabela",
"columns": {
"name": "...",
"description": "...",
"rarity": "...",
"metadata": {...},
"outros_campos": ...
"name": string | null,
"description": string | null,
"category": string | null,
"rarity": string | null,
"image_url": string | null,
"image_local_path": string | null,
"level": number | null,
"weight": number | null,
"slots": number | null,
"skill": string | null,
"servers": string[] | null,
"tags": string[] | null,
"metadata": object | null
},
"schemaSuggestion": {
"table": "nome_da_tabela_existente_ou_nova",
"columns": [
{ "name": "coluna_1", "type": "TEXT|INTEGER|REAL|BOOLEAN" },
{ "name": "coluna_2", "type": "TEXT|INTEGER|REAL|BOOLEAN" }
]
}
}
Consistência:
- Escolha a tabela coerente com o escopo. Para global, use "items" ou equivalente.
- Escolha a tabela coerente com o escopo. Para global, use "items_all".
- Para escopo de servidor, use "items_<slug_do_servidor>", onde <slug> é o nome do servidor em minúsculas, com espaços transformados em "_" e removendo caracteres não alfanuméricos.
- Inclua sempre os campos básicos: name, description.
- Aplique outros campos conforme a descrição (ex.: atributos, requisitos, bônus).
- Se houver informações que encaixem em metadados dinâmicos, use um objeto "metadata".
- Nunca produza "description" com mais de 100 caracteres.
Regras adicionais:
- Servidores ("servers"): utilize SOMENTE nomes presentes na lista fornecida no contexto. Prefira o formato array de strings (ex.: ["Belobra", "Antica"]). Se não aplicável, use null.
- Metadata: inclua apenas atributos descritivos (ex.: "attributes", "requirements", "protections", "flags"). É PROIBIDO incluir qualquer campo de valor/preço/mercado. Não utilize chaves: "price", "value", "buy", "sell", "currency", "npc_price", "marketPrice", "minPrice", "maxPrice", "avgPrice".
- Tipos: números devem ser números (sem aspas). Se o valor for desconhecido, retorne null. Não invente dados.
${extraInstructions ?? ""}
`.trim();
@@ -127,13 +151,13 @@ export interface InlineImageData {
}
export async function buildGeminiParts(prompt: string, images: InlineImageData[] = []) {
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [
const parts: Array<{ text?: string; inline_data?: { mimeType: string; data: string } }> = [
{ text: prompt },
];
images.slice(0, 2).forEach((image) => {
parts.push({
inlineData: {
inline_data: {
mimeType: image.mimeType,
data: image.base64Data,
},
+61
View File
@@ -0,0 +1,61 @@
/**
* Utilitário de preços de tokens do Gemini e cálculo de custo por requisição
* Baseado em preços públicos aproximados para o modelo `gemini-2.5-flash`.
* Valores podem variar; mantenha sob revisão se houver mudança na tabela oficial.
*/
export interface UsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
}
export interface TokenCostBreakdown {
inputTokens: number;
outputTokens: number;
totalTokens: number;
inputUsd: number;
outputUsd: number;
totalUsd: number;
}
type PricingTable = {
[model: string]: {
inputPerMillionTextImageVideo: number; // USD por 1M tokens de entrada (text/image/video)
outputPerMillion: number; // USD por 1M tokens de saída
};
};
// Tabela simplificada; ajuste se usar outros modelos no futuro
export const TOKEN_PRICING: PricingTable = {
"gemini-2.5-flash": {
inputPerMillionTextImageVideo: 0.15, // USD
outputPerMillion: 1.25, // USD
},
};
/**
* Calcula custo em USD baseado em usageMetadata retornado pela API Gemini.
*/
export function calculateTokenCost(
model: keyof typeof TOKEN_PRICING,
usage: UsageMetadata | null | undefined,
): TokenCostBreakdown {
const pricing = TOKEN_PRICING[model] ?? TOKEN_PRICING["gemini-2.5-flash"];
const inputTokens = Math.max(0, Number(usage?.promptTokenCount || 0));
const outputTokens = Math.max(0, Number(usage?.candidatesTokenCount || 0));
const totalTokens = Math.max(0, Number(usage?.totalTokenCount || inputTokens + outputTokens));
const inputUsd = (inputTokens / 1_000_000) * pricing.inputPerMillionTextImageVideo;
const outputUsd = (outputTokens / 1_000_000) * pricing.outputPerMillion;
const totalUsd = inputUsd + outputUsd;
return {
inputTokens,
outputTokens,
totalTokens,
inputUsd,
outputUsd,
totalUsd,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { getRedisClient } from "../redis";
export interface TokenUsageRecord {
id: string; // uuid/id da requisição (pode ser requestId)
timestamp: number;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
context?: Record<string, unknown>; // dados opcionais (ex.: rota, itemId)
}
const KEY_PREFIX = "ai:usage";
export async function recordTokenUsage(record: TokenUsageRecord): Promise<void> {
const redis = await getRedisClient();
const dayKey = `${KEY_PREFIX}:day:${new Date(record.timestamp).toISOString().slice(0, 10)}`; // YYYY-MM-DD
const listKey = `${KEY_PREFIX}:records`;
// Armazenar agregado diário
const pipeline = redis.multi();
pipeline.hIncrBy(dayKey, "inputTokens", record.inputTokens);
pipeline.hIncrBy(dayKey, "outputTokens", record.outputTokens);
pipeline.hIncrBy(dayKey, "totalTokens", record.totalTokens);
pipeline.hIncrByFloat(dayKey, "costUsd", record.costUsd);
// Armazenar registro detalhado (para relatórios)
pipeline.lPush(listKey, JSON.stringify(record));
// Manter os últimos 10k registros para não crescer indefinidamente (ajuste conforme necessidade)
pipeline.lTrim(listKey, 0, 9999);
await pipeline.exec();
}
+27
View File
@@ -211,11 +211,38 @@ export interface UpsertItemInput {
data: Record<string, unknown>;
}
function ensureServerColumn(category: string) {
const db = getDatabase();
const normalized = category.trim().toLowerCase();
validateCategoryName(normalized);
const pragma = db.prepare(`PRAGMA table_info(${normalized})`).all() as Array<{
name: string;
type: string;
notnull: number;
pk: number;
}>;
const hasServer = pragma.some((col) => col.name === "server");
if (!hasServer) {
// Adiciona coluna 'server' como TEXT opcional
addColumnToCategory({
category: normalized,
column: { name: "server", type: "TEXT", notNull: false, editable: true },
});
}
}
export function insertCategoryItem(input: UpsertItemInput): number {
const db = getDatabase();
const category = input.category.trim().toLowerCase();
validateCategoryName(category);
// Garante que a coluna 'server' exista na tabela de destino.
try {
ensureServerColumn(category);
} catch {}
const data = { ...input.data };
delete data.id;
+52
View File
@@ -6,6 +6,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "../app";
import { ensureItemsSeeded } from "../../items/import-from-sqlite";
import { secureLogger } from "../../secure-logger";
export type ItemSort =
| "name"
@@ -193,6 +194,53 @@ function buildMetadata(
return merged;
}
// Campos relacionados a valores/preços que NÃO pertencem ao modelo Item.
// Qualquer presença deles em metadata deve ser rejeitada.
const FORBIDDEN_VALUE_KEYS = new Set<string>([
// Geral
"price",
"prices",
"valor",
"value",
"sell_value",
"sellPrice",
"buy",
"buy_price",
// Chaves usadas em Listings
"price_pix",
"price_tibia_coins",
"price_gold_coins",
"initial_price_pix",
"initial_price_tibia_coins",
"initial_price_gold_coins",
"current_bid_amount",
]);
/**
* Verifica recursivamente se há campos proibidos relacionados a valores/preços.
* Lança erro se encontrar.
*/
export function assertNoValueFieldsInMetadata(meta: Record<string, any> | undefined | null, context?: string): void {
if (!meta || typeof meta !== "object") return;
const stack: Array<{ obj: Record<string, any>; path: string }> = [{ obj: meta as Record<string, any>, path: "metadata" }];
while (stack.length) {
const { obj, path } = stack.pop()!;
for (const key of Object.keys(obj)) {
const nextPath = `${path}.${key}`;
if (FORBIDDEN_VALUE_KEYS.has(key)) {
const msg = `Itens não possuem campos de valor/preço no cadastro. Campo proibido: ${nextPath}. Use listings para preços.`;
secureLogger.warn(msg, { context });
throw new Error(msg);
}
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) {
stack.push({ obj: val as Record<string, any>, path: nextPath });
}
}
}
}
export async function getItemById(itemId: number): Promise<Item | null> {
const item = await prisma.item.findUnique({ where: { item_id: itemId } });
if (!item) {
@@ -322,6 +370,8 @@ export async function searchItems(params: SearchItemsParams = {}): Promise<{ ite
export async function createAdminItem(input: ItemUpsertInput): Promise<ItemWithMeta> {
const now = Date.now();
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
assertNoValueFieldsInMetadata(input.metadata, "createAdminItem");
const data: Prisma.ItemCreateInput = {
source_table_id: input.source_table_id ?? now,
source_table_name: input.source_table_name ?? "custom",
@@ -353,6 +403,8 @@ export async function updateAdminItem(itemId: number, input: ItemUpsertInput): P
}
const existingMeta = parseMetadata(existing.metadata);
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
assertNoValueFieldsInMetadata(input.metadata, "updateAdminItem");
const metadata = buildMetadata(existingMeta, {
servers: input.servers ?? extractServers(existingMeta),
tags: input.tags ?? extractTags(existingMeta),
+28
View File
@@ -446,3 +446,31 @@ export function validatePasswordRequirements(password: string): {
};
}
/**
* Mapeia o locale da aplicação (ex: "pt", "en") para um BCP 47 válido
* para APIs de formatação (ex: "pt-BR", "en-US").
*/
export function resolveBCP47Locale(locale?: string): string {
switch (locale) {
case "pt":
return "pt-BR";
case "en":
return "en-US";
default:
// Fallback consistente para evitar divergência SSR/cliente
return "pt-BR";
}
}
/**
* Formata números com locale consistente, evitando mismatch de hidratação
* entre SSR e cliente.
*/
export function formatNumber(value: number, locale?: string): string {
try {
return value.toLocaleString(resolveBCP47Locale(locale));
} catch {
return String(value);
}
}