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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+34
-10
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user