refactor: modulariza insights e atualiza catálogo de IA

This commit is contained in:
Felipe Coutinho
2026-03-20 18:41:34 +00:00
parent 29551ee02f
commit f77c64325d
7 changed files with 868 additions and 892 deletions

View File

@@ -1,871 +1,32 @@
"use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { getDay } from "date-fns";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { generateInsightsAction as generateInsightsActionImpl } from "./actions/generate";
import {
budgets,
cards,
categories,
financialAccounts,
payers,
savedInsights,
transactions,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/shared/lib/schemas/insights";
import { getPreviousPeriod } from "@/shared/utils/period";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./constants";
deleteSavedInsightsAction as deleteSavedInsightsActionImpl,
loadSavedInsightsAction as loadSavedInsightsActionImpl,
saveInsightsAction as saveInsightsActionImpl,
} from "./actions/storage";
const TRANSFERENCIA = "Transferência";
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
/**
* Função auxiliar para converter valores numéricos
*/
const toNumber = (value: unknown): number => {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
/**
* Agrega dados financeiros do mês para análise
*/
async function aggregateMonthData(userId: string, period: string) {
const previousPeriod = getPreviousPeriod(period);
const twoMonthsAgo = getPreviousPeriod(previousPeriod);
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
// Buscar métricas de receitas e despesas dos últimos 3 meses
const [
currentPeriodRows,
previousPeriodRows,
twoMonthsAgoRows,
threeMonthsAgoRows,
] = await Promise.all([
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
sql`${
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, previousPeriod),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
sql`${
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, twoMonthsAgo),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
sql`${
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, threeMonthsAgo),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
or(
isNull(transactions.note),
sql`${
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(transactions.transactionType),
]);
// Calcular totais dos últimos 3 meses
let currentIncome = 0;
let currentExpense = 0;
let previousIncome = 0;
let previousExpense = 0;
let twoMonthsAgoIncome = 0;
let twoMonthsAgoExpense = 0;
let threeMonthsAgoIncome = 0;
let threeMonthsAgoExpense = 0;
for (const row of currentPeriodRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") currentIncome += amount;
else if (row.transactionType === "Despesa") currentExpense += amount;
}
for (const row of previousPeriodRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") previousIncome += amount;
else if (row.transactionType === "Despesa") previousExpense += amount;
}
for (const row of twoMonthsAgoRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") twoMonthsAgoIncome += amount;
else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount;
}
for (const row of threeMonthsAgoRows) {
const amount = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === "Receita") threeMonthsAgoIncome += amount;
else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount;
}
// Buscar despesas por categoria (top 5)
const expensesByCategory = await db
.select({
categoryName: categories.name,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.transactionType, "Despesa"),
eq(payers.role, PAYER_ROLE_ADMIN),
eq(categories.type, "despesa"),
or(
isNull(transactions.note),
sql`${
transactions.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(categories.name)
.orderBy(sql`sum(${transactions.amount}) ASC`)
.limit(5);
// Buscar orçamentos e uso
const budgetsData = await db
.select({
categoryName: categories.name,
budgetAmount: budgets.amount,
spent: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(
transactions,
and(
eq(transactions.categoryId, categories.id),
eq(transactions.period, period),
eq(transactions.userId, userId),
eq(transactions.transactionType, "Despesa"),
),
)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
.groupBy(categories.name, budgets.amount);
// Buscar métricas de cartões
const cardsData = await db
.select({
totalLimit: sql<number>`coalesce(sum(${cards.limit}), 0)`,
cardCount: sql<number>`count(*)`,
})
.from(cards)
.where(and(eq(cards.userId, userId), eq(cards.status, "ativo")));
// Buscar saldo total das financialAccounts
const accountsData = await db
.select({
totalBalance: sql<number>`coalesce(sum(${financialAccounts.initialBalance}), 0)`,
accountCount: sql<number>`count(*)`,
})
.from(financialAccounts)
.where(
and(
eq(financialAccounts.userId, userId),
eq(financialAccounts.status, "ativa"),
eq(financialAccounts.excludeFromBalance, false),
),
);
// Calcular ticket médio das transações
const avgTicketData = await db
.select({
avgAmount: sql<number>`coalesce(avg(abs(${transactions.amount})), 0)`,
transactionCount: sql<number>`count(*)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
),
);
// Buscar gastos por dia da semana
const dayOfWeekSpending = await db
.select({
purchaseDate: transactions.purchaseDate,
amount: transactions.amount,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.transactionType, "Despesa"),
eq(payers.role, PAYER_ROLE_ADMIN),
),
);
// Agregar por dia da semana
const dayTotals = new Map<number, number>();
for (const row of dayOfWeekSpending) {
if (!row.purchaseDate) continue;
const dayOfWeek = getDay(new Date(row.purchaseDate));
const current = dayTotals.get(dayOfWeek) ?? 0;
dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount)));
}
// Buscar métodos de pagamento (agregado)
const paymentMethodsData = await db
.select({
paymentMethod: transactions.paymentMethod,
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.where(
and(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.transactionType, "Despesa"),
eq(payers.role, PAYER_ROLE_ADMIN),
),
)
.groupBy(transactions.paymentMethod);
// Buscar transações dos últimos 3 meses para análise de recorrência
const last3MonthsTransactions = await db
.select({
name: transactions.name,
amount: transactions.amount,
period: transactions.period,
condition: transactions.condition,
installmentCount: transactions.installmentCount,
currentInstallment: transactions.currentInstallment,
categoryName: categories.name,
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
eq(transactions.userId, userId),
sql`${transactions.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
eq(transactions.transactionType, "Despesa"),
eq(payers.role, PAYER_ROLE_ADMIN),
ne(transactions.transactionType, TRANSFERENCIA),
),
)
.orderBy(transactions.name);
// Análise de recorrência
const transactionsByName = new Map<
string,
Array<{ period: string; amount: number }>
>();
for (const tx of last3MonthsTransactions) {
const key = tx.name.toLowerCase().trim();
if (!transactionsByName.has(key)) {
transactionsByName.set(key, []);
}
const transactions = transactionsByName.get(key);
if (transactions) {
transactions.push({
period: tx.period,
amount: Math.abs(toNumber(tx.amount)),
});
}
}
// Identificar gastos recorrentes (aparece em 2+ meses com valor similar)
const recurringExpenses: Array<{
name: string;
avgAmount: number;
frequency: number;
}> = [];
let totalRecurring = 0;
for (const [name, occurrences] of transactionsByName.entries()) {
if (occurrences.length >= 2) {
const amounts = occurrences.map((o) => o.amount);
const avgAmount =
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
// Considerar recorrente se variação <= 20% da média
if (maxDiff <= avgAmount * 0.2) {
recurringExpenses.push({
name,
avgAmount,
frequency: occurrences.length,
});
// Somar apenas os do mês atual
const currentMonthOccurrence = occurrences.find(
(o) => o.period === period,
);
if (currentMonthOccurrence) {
totalRecurring += currentMonthOccurrence.amount;
}
}
}
}
// Análise de gastos parcelados
const installmentTransactions = last3MonthsTransactions.filter(
(tx) =>
tx.condition === "Parcelado" &&
tx.installmentCount &&
tx.installmentCount > 1,
);
const installmentData = installmentTransactions
.filter((tx) => tx.period === period)
.map((tx) => ({
name: tx.name,
currentInstallment: tx.currentInstallment ?? 1,
totalInstallments: tx.installmentCount ?? 1,
amount: Math.abs(toNumber(tx.amount)),
category: tx.categoryName ?? "Outros",
}));
const totalInstallmentAmount = installmentData.reduce(
(sum, tx) => sum + tx.amount,
0,
);
const futureCommitment = installmentData.reduce((sum, tx) => {
const remaining = tx.totalInstallments - tx.currentInstallment;
return sum + tx.amount * remaining;
}, 0);
// Montar dados agregados e anonimizados
const aggregatedData = {
month: period,
totalIncome: currentIncome,
totalExpense: currentExpense,
balance: currentIncome - currentExpense,
// Tendência de 3 meses
threeMonthTrend: {
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
incomes: [
threeMonthsAgoIncome,
twoMonthsAgoIncome,
previousIncome,
currentIncome,
],
expenses: [
threeMonthsAgoExpense,
twoMonthsAgoExpense,
previousExpense,
currentExpense,
],
avgIncome:
(threeMonthsAgoIncome +
twoMonthsAgoIncome +
previousIncome +
currentIncome) /
4,
avgExpense:
(threeMonthsAgoExpense +
twoMonthsAgoExpense +
previousExpense +
currentExpense) /
4,
trend:
currentExpense > previousExpense &&
previousExpense > twoMonthsAgoExpense
? "crescente"
: currentExpense < previousExpense &&
previousExpense < twoMonthsAgoExpense
? "decrescente"
: "estável",
},
previousMonthIncome: previousIncome,
previousMonthExpense: previousExpense,
monthOverMonthIncomeChange:
Math.abs(previousIncome) > 0.01
? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100
: 0,
monthOverMonthExpenseChange:
Math.abs(previousExpense) > 0.01
? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100
: 0,
savingsRate:
currentIncome > 0.01
? ((currentIncome - currentExpense) / currentIncome) * 100
: 0,
topExpenseCategories: expensesByCategory.map(
(cat: { categoryName: string; total: unknown }) => ({
category: cat.categoryName,
amount: Math.abs(toNumber(cat.total)),
percentageOfTotal:
currentExpense > 0
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
: 0,
}),
),
budgets: budgetsData.map(
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
category: b.categoryName,
budgetAmount: toNumber(b.budgetAmount),
spent: Math.abs(toNumber(b.spent)),
usagePercentage:
toNumber(b.budgetAmount) > 0
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
: 0,
}),
),
creditCards: {
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
cardCount: toNumber(cardsData[0]?.cardCount ?? 0),
},
accounts: {
totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0),
accountCount: toNumber(accountsData[0]?.accountCount ?? 0),
},
avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0),
transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0),
dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({
dayOfWeek:
["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A",
total,
})),
paymentMethodsBreakdown: paymentMethodsData.map(
(pm: { paymentMethod: string | null; total: unknown }) => ({
method: pm.paymentMethod,
total: toNumber(pm.total),
percentage:
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
}),
),
// Análise de recorrência
recurringExpenses: {
count: recurringExpenses.length,
total: totalRecurring,
percentageOfTotal:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
topRecurring: recurringExpenses
.sort((a, b) => b.avgAmount - a.avgAmount)
.slice(0, 5)
.map((r) => ({
name: r.name,
avgAmount: r.avgAmount,
frequency: r.frequency,
})),
predictability:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
},
// Análise de parcelamentos
installments: {
currentMonthInstallments: installmentData.length,
totalInstallmentAmount,
percentageOfExpenses:
currentExpense > 0
? (totalInstallmentAmount / currentExpense) * 100
: 0,
futureCommitment,
topInstallments: installmentData
.sort((a, b) => b.amount - a.amount)
.slice(0, 5)
.map((i) => ({
name: i.name,
current: i.currentInstallment,
total: i.totalInstallments,
amount: i.amount,
category: i.category,
remaining: i.totalInstallments - i.currentInstallment,
})),
},
};
return aggregatedData;
}
/**
* Gera insights usando IA
*/
export async function generateInsightsAction(
period: string,
modelId: string,
): Promise<ActionResult<InsightsResponse>> {
try {
const user = await getUser();
// Validar modelo - verificar se existe na lista ou se é um modelo customizado
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
// Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(
modelId,
);
if (!selectedModel && !isOpenRouterFormat) {
return {
success: false,
error: "Modelo inválido.",
};
}
// Agregar dados
const aggregatedData = await aggregateMonthData(user.id, period);
// Selecionar provider
let model: ReturnType<typeof google>;
// Se o modelo tem "/" é OpenRouter (formato: provider/model)
if (isOpenRouterFormat && !selectedModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouter = createOpenRouter({
apiKey,
});
model = openrouter.chat(modelId);
} else if (selectedModel?.provider === "openai") {
model = openai(modelId);
} else if (selectedModel?.provider === "anthropic") {
model = anthropic(modelId);
} else if (selectedModel?.provider === "google") {
model = google(modelId);
} else {
return {
success: false,
error: "Provider de modelo não suportado.",
};
}
// Chamar AI SDK
const result = await generateObject({
model,
schema: InsightsResponseSchema,
system: INSIGHTS_SYSTEM_PROMPT,
prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
Dados agregados:
${JSON.stringify(aggregatedData, null, 2)}
DADOS IMPORTANTES PARA SUA ANÁLISE:
**Tendência de 3 meses:**
- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend)
- Use isso para identificar padrões crescentes, decrescentes ou estáveis
- Compare o mês atual com a média dos 3 meses
**Análise de Recorrência:**
- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas
- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes
- Use isso para avaliar previsibilidade e oportunidades de otimização
**Gastos Parcelados:**
- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
- Use isso para alertas sobre comprometimento de renda futura
Organize suas observações nas 4 categories especificadas no prompt do sistema:
1. Comportamentos Observados (behaviors): 3-6 itens
2. Gatilhos de Consumo (triggers): 3-6 itens
3. Recomendações Práticas (recommendations): 3-6 itens
4. Melhorias Sugeridas (improvements): 3-6 itens
Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos.
Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
});
// Validar resposta
const validatedData = InsightsResponseSchema.parse(result.object);
return {
success: true,
data: validatedData,
};
} catch (error) {
console.error("Error generating insights:", error);
return {
success: false,
error: "Erro ao gerar insights. Tente novamente.",
};
}
...args: Parameters<typeof generateInsightsActionImpl>
): ReturnType<typeof generateInsightsActionImpl> {
return generateInsightsActionImpl(...args);
}
/**
* Salva insights gerados no banco de dados
*/
export async function saveInsightsAction(
period: string,
modelId: string,
data: InsightsResponse,
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
try {
const user = await getUser();
// Verificar se já existe um insight salvo para este período
const existing = await db
.select()
.from(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
if (existing.length > 0) {
// Atualizar existente
const updated = await db
.update(savedInsights)
.set({
modelId,
data: JSON.stringify(data),
updatedAt: new Date(),
})
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.returning({
id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const updatedRecord = updated[0];
if (!updatedRecord) {
return {
success: false,
error: "Falha ao atualizar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: updatedRecord.id,
createdAt: updatedRecord.createdAt,
},
};
}
// Criar novo
const result = await db
.insert(savedInsights)
.values({
userId: user.id,
period,
modelId,
data: JSON.stringify(data),
})
.returning({
id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const insertedRecord = result[0];
if (!insertedRecord) {
return {
success: false,
error: "Falha ao salvar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: insertedRecord.id,
createdAt: insertedRecord.createdAt,
},
};
} catch (error) {
console.error("Error saving insights:", error);
return {
success: false,
error: "Erro ao salvar análise. Tente novamente.",
};
}
...args: Parameters<typeof saveInsightsActionImpl>
): ReturnType<typeof saveInsightsActionImpl> {
return saveInsightsActionImpl(...args);
}
/**
* Carrega insights salvos do banco de dados
*/
export async function loadSavedInsightsAction(period: string): Promise<
ActionResult<{
insights: InsightsResponse;
modelId: string;
createdAt: Date;
} | null>
> {
try {
const user = await getUser();
const result = await db
.select()
.from(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
if (result.length === 0) {
return {
success: true,
data: null,
};
}
const saved = result[0];
if (!saved) {
return {
success: true,
data: null,
};
}
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
return {
success: true,
data: {
insights,
modelId: saved.modelId,
createdAt: saved.createdAt,
},
};
} catch (error) {
console.error("Error loading saved insights:", error);
return {
success: false,
error: "Erro ao carregar análise salva. Tente novamente.",
};
}
export async function loadSavedInsightsAction(
...args: Parameters<typeof loadSavedInsightsActionImpl>
): ReturnType<typeof loadSavedInsightsActionImpl> {
return loadSavedInsightsActionImpl(...args);
}
/**
* Remove insights salvos do banco de dados
*/
export async function deleteSavedInsightsAction(
period: string,
): Promise<ActionResult<void>> {
try {
const user = await getUser();
await db
.delete(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
);
return {
success: true,
data: undefined,
};
} catch (error) {
console.error("Error deleting saved insights:", error);
return {
success: false,
error: "Erro ao remover análise. Tente novamente.",
};
}
...args: Parameters<typeof deleteSavedInsightsActionImpl>
): ReturnType<typeof deleteSavedInsightsActionImpl> {
return deleteSavedInsightsActionImpl(...args);
}

View File

@@ -0,0 +1,493 @@
import { getDay } from "date-fns";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { unstable_cache } from "next/cache";
import {
budgets,
cards,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
import { getPreviousPeriod } from "@/shared/utils/period";
const TRANSFERENCIA = "Transferência";
async function aggregateMonthDataInternal(userId: string, period: string) {
const previousPeriod = getPreviousPeriod(period);
const twoMonthsAgo = getPreviousPeriod(previousPeriod);
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
const adminPayerId = await getAdminPayerId(userId);
const autoInvoiceExclusion =
or(
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
) ?? sql`true`;
const adminPayerCondition = adminPayerId
? eq(transactions.payerId, adminPayerId)
: sql`false`;
const buildAdminTransactionConditions = ({
period: singlePeriod,
periods,
transactionType,
excludeTransfers = true,
excludeAutoInvoice = true,
}: {
period?: string;
periods?: string[];
transactionType?: string;
excludeTransfers?: boolean;
excludeAutoInvoice?: boolean;
}) => {
const conditions = [eq(transactions.userId, userId), adminPayerCondition];
if (singlePeriod) {
conditions.push(eq(transactions.period, singlePeriod));
}
if (periods && periods.length > 0) {
conditions.push(inArray(transactions.period, periods));
}
if (transactionType) {
conditions.push(eq(transactions.transactionType, transactionType));
}
if (excludeTransfers) {
conditions.push(ne(transactions.transactionType, TRANSFERENCIA));
}
if (excludeAutoInvoice) {
conditions.push(autoInvoiceExclusion);
}
return conditions;
};
const [
currentPeriodRows,
previousPeriodRows,
twoMonthsAgoRows,
threeMonthsAgoRows,
expensesByCategory,
budgetsData,
cardsData,
accountsData,
avgTicketData,
dayOfWeekSpending,
paymentMethodsData,
last3MonthsTransactions,
] = await Promise.all([
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(and(...buildAdminTransactionConditions({ period })))
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(...buildAdminTransactionConditions({ period: previousPeriod })),
)
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo })))
.groupBy(transactions.transactionType),
db
.select({
transactionType: transactions.transactionType,
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.where(
and(...buildAdminTransactionConditions({ period: threeMonthsAgo })),
)
.groupBy(transactions.transactionType),
db
.select({
categoryName: categories.name,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
...buildAdminTransactionConditions({
period,
transactionType: "Despesa",
}),
eq(categories.type, "despesa"),
),
)
.groupBy(categories.name)
.orderBy(sql`sum(${transactions.amount}) ASC`)
.limit(5),
db
.select({
categoryName: categories.name,
budgetAmount: budgets.amount,
spent: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
.leftJoin(
transactions,
and(
eq(transactions.categoryId, categories.id),
eq(transactions.period, period),
eq(transactions.userId, userId),
eq(transactions.transactionType, "Despesa"),
adminPayerCondition,
autoInvoiceExclusion,
),
)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
.groupBy(categories.name, budgets.amount),
db
.select({
totalLimit: sql<number>`coalesce(sum(${cards.limit}), 0)`,
cardCount: sql<number>`count(*)`,
})
.from(cards)
.where(and(eq(cards.userId, userId), eq(cards.status, "ativo"))),
db
.select({
totalBalance: sql<number>`coalesce(sum(${financialAccounts.initialBalance}), 0)`,
accountCount: sql<number>`count(*)`,
})
.from(financialAccounts)
.where(
and(
eq(financialAccounts.userId, userId),
eq(financialAccounts.status, "ativa"),
eq(financialAccounts.excludeFromBalance, false),
),
),
db
.select({
avgAmount: sql<number>`coalesce(avg(abs(${transactions.amount})), 0)`,
transactionCount: sql<number>`count(*)`,
})
.from(transactions)
.where(and(...buildAdminTransactionConditions({ period }))),
db
.select({
purchaseDate: transactions.purchaseDate,
amount: transactions.amount,
})
.from(transactions)
.where(
and(
...buildAdminTransactionConditions({
period,
transactionType: "Despesa",
}),
),
),
db
.select({
paymentMethod: transactions.paymentMethod,
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(transactions)
.where(
and(
...buildAdminTransactionConditions({
period,
transactionType: "Despesa",
}),
),
)
.groupBy(transactions.paymentMethod),
db
.select({
name: transactions.name,
amount: transactions.amount,
period: transactions.period,
condition: transactions.condition,
installmentCount: transactions.installmentCount,
currentInstallment: transactions.currentInstallment,
categoryName: categories.name,
})
.from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.where(
and(
...buildAdminTransactionConditions({
periods: [period, previousPeriod, twoMonthsAgo],
transactionType: "Despesa",
}),
),
)
.orderBy(transactions.name),
]);
const sumByType = (
rows: Array<{ transactionType: string | null; totalAmount: unknown }>,
) => {
let income = 0;
let expense = 0;
for (const row of rows) {
const amount = Math.abs(safeToNumber(row.totalAmount));
if (row.transactionType === "Receita") income += amount;
else if (row.transactionType === "Despesa") expense += amount;
}
return { income, expense };
};
const { income: currentIncome, expense: currentExpense } =
sumByType(currentPeriodRows);
const { income: previousIncome, expense: previousExpense } =
sumByType(previousPeriodRows);
const { income: twoMonthsAgoIncome, expense: twoMonthsAgoExpense } =
sumByType(twoMonthsAgoRows);
const { income: threeMonthsAgoIncome, expense: threeMonthsAgoExpense } =
sumByType(threeMonthsAgoRows);
const dayTotals = new Map<number, number>();
for (const row of dayOfWeekSpending) {
if (!row.purchaseDate) continue;
const dayOfWeek = getDay(new Date(row.purchaseDate));
const current = dayTotals.get(dayOfWeek) ?? 0;
dayTotals.set(dayOfWeek, current + Math.abs(safeToNumber(row.amount)));
}
const transactionsByName = new Map<
string,
Array<{ period: string; amount: number }>
>();
for (const tx of last3MonthsTransactions) {
const key = tx.name.toLowerCase().trim();
if (!transactionsByName.has(key)) {
transactionsByName.set(key, []);
}
const transactionsList = transactionsByName.get(key);
if (transactionsList) {
transactionsList.push({
period: tx.period,
amount: Math.abs(safeToNumber(tx.amount)),
});
}
}
const recurringExpenses: Array<{
name: string;
avgAmount: number;
frequency: number;
}> = [];
let totalRecurring = 0;
for (const [name, occurrences] of transactionsByName.entries()) {
if (occurrences.length >= 2) {
const amounts = occurrences.map((o) => o.amount);
const avgAmount =
amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
if (maxDiff <= avgAmount * 0.2) {
recurringExpenses.push({
name,
avgAmount,
frequency: occurrences.length,
});
const currentMonthOccurrence = occurrences.find(
(o) => o.period === period,
);
if (currentMonthOccurrence) {
totalRecurring += currentMonthOccurrence.amount;
}
}
}
}
const installmentTransactions = last3MonthsTransactions.filter(
(tx) =>
tx.condition === "Parcelado" &&
tx.installmentCount &&
tx.installmentCount > 1,
);
const installmentData = installmentTransactions
.filter((tx) => tx.period === period)
.map((tx) => ({
name: tx.name,
currentInstallment: tx.currentInstallment ?? 1,
totalInstallments: tx.installmentCount ?? 1,
amount: Math.abs(safeToNumber(tx.amount)),
category: tx.categoryName ?? "Outros",
}));
const totalInstallmentAmount = installmentData.reduce(
(sum, tx) => sum + tx.amount,
0,
);
const futureCommitment = installmentData.reduce((sum, tx) => {
const remaining = tx.totalInstallments - tx.currentInstallment;
return sum + tx.amount * remaining;
}, 0);
return {
month: period,
totalIncome: currentIncome,
totalExpense: currentExpense,
balance: currentIncome - currentExpense,
threeMonthTrend: {
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
incomes: [
threeMonthsAgoIncome,
twoMonthsAgoIncome,
previousIncome,
currentIncome,
],
expenses: [
threeMonthsAgoExpense,
twoMonthsAgoExpense,
previousExpense,
currentExpense,
],
avgIncome:
(threeMonthsAgoIncome +
twoMonthsAgoIncome +
previousIncome +
currentIncome) /
4,
avgExpense:
(threeMonthsAgoExpense +
twoMonthsAgoExpense +
previousExpense +
currentExpense) /
4,
trend:
currentExpense > previousExpense &&
previousExpense > twoMonthsAgoExpense
? "crescente"
: currentExpense < previousExpense &&
previousExpense < twoMonthsAgoExpense
? "decrescente"
: "estável",
},
previousMonthIncome: previousIncome,
previousMonthExpense: previousExpense,
monthOverMonthIncomeChange:
Math.abs(previousIncome) > 0.01
? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100
: 0,
monthOverMonthExpenseChange:
Math.abs(previousExpense) > 0.01
? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100
: 0,
savingsRate:
currentIncome > 0.01
? ((currentIncome - currentExpense) / currentIncome) * 100
: 0,
topExpenseCategories: expensesByCategory.map(
(cat: { categoryName: string; total: unknown }) => ({
category: cat.categoryName,
amount: Math.abs(safeToNumber(cat.total)),
percentageOfTotal:
currentExpense > 0
? (Math.abs(safeToNumber(cat.total)) / currentExpense) * 100
: 0,
}),
),
budgets: budgetsData.map(
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
category: b.categoryName,
budgetAmount: safeToNumber(b.budgetAmount),
spent: Math.abs(safeToNumber(b.spent)),
usagePercentage:
safeToNumber(b.budgetAmount) > 0
? (Math.abs(safeToNumber(b.spent)) / safeToNumber(b.budgetAmount)) *
100
: 0,
}),
),
creditCards: {
totalLimit: safeToNumber(cardsData[0]?.totalLimit ?? 0),
cardCount: safeToNumber(cardsData[0]?.cardCount ?? 0),
},
accounts: {
totalBalance: safeToNumber(accountsData[0]?.totalBalance ?? 0),
accountCount: safeToNumber(accountsData[0]?.accountCount ?? 0),
},
avgTicket: safeToNumber(avgTicketData[0]?.avgAmount ?? 0),
transactionCount: safeToNumber(avgTicketData[0]?.transactionCount ?? 0),
dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({
dayOfWeek:
["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A",
total,
})),
paymentMethodsBreakdown: paymentMethodsData.map(
(pm: { paymentMethod: string | null; total: unknown }) => ({
method: pm.paymentMethod,
total: safeToNumber(pm.total),
percentage:
currentExpense > 0
? (safeToNumber(pm.total) / currentExpense) * 100
: 0,
}),
),
recurringExpenses: {
count: recurringExpenses.length,
total: totalRecurring,
percentageOfTotal:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
topRecurring: recurringExpenses
.sort((a, b) => b.avgAmount - a.avgAmount)
.slice(0, 5)
.map((r) => ({
name: r.name,
avgAmount: r.avgAmount,
frequency: r.frequency,
})),
predictability:
currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
},
installments: {
currentMonthInstallments: installmentData.length,
totalInstallmentAmount,
percentageOfExpenses:
currentExpense > 0
? (totalInstallmentAmount / currentExpense) * 100
: 0,
futureCommitment,
topInstallments: installmentData
.sort((a, b) => b.amount - a.amount)
.slice(0, 5)
.map((i) => ({
name: i.name,
current: i.currentInstallment,
total: i.totalInstallments,
amount: i.amount,
category: i.category,
remaining: i.totalInstallments - i.currentInstallment,
})),
},
};
}
export function aggregateMonthData(userId: string, period: string) {
return unstable_cache(
() => aggregateMonthDataInternal(userId, period),
[`insights-aggregate-${userId}-${period}`],
{
tags: [`dashboard-${userId}`],
revalidate: 60,
},
)();
}

View File

@@ -0,0 +1,125 @@
"use server";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { generateObject } from "ai";
import { getUser } from "@/shared/lib/auth/server";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/shared/lib/schemas/insights";
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "../constants";
import { aggregateMonthData } from "./aggregate";
import type { ActionResult } from "./types";
const PERIOD_REGEX = /^\d{4}-\d{2}$/;
export async function generateInsightsAction(
period: string,
modelId: string,
): Promise<ActionResult<InsightsResponse>> {
try {
const user = await getUser();
if (!PERIOD_REGEX.test(period)) {
return {
success: false,
error: "Período inválido (formato esperado: YYYY-MM)",
};
}
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
const isOpenRouterFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(
modelId,
);
if (!selectedModel && !isOpenRouterFormat) {
return {
success: false,
error: "Modelo inválido.",
};
}
const aggregatedData = await aggregateMonthData(user.id, period);
let model: ReturnType<typeof google>;
if (isOpenRouterFormat && !selectedModel) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return {
success: false,
error:
"OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
};
}
const openrouter = createOpenRouter({
apiKey,
});
model = openrouter.chat(modelId);
} else if (selectedModel?.provider === "openai") {
model = openai(modelId);
} else if (selectedModel?.provider === "anthropic") {
model = anthropic(modelId);
} else if (selectedModel?.provider === "google") {
model = google(modelId);
} else {
return {
success: false,
error: "Provider de modelo não suportado.",
};
}
const result = await generateObject({
model,
schema: InsightsResponseSchema,
system: INSIGHTS_SYSTEM_PROMPT,
prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
Dados agregados:
${JSON.stringify(aggregatedData, null, 2)}
DADOS IMPORTANTES PARA SUA ANÁLISE:
**Tendência de 3 meses:**
- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend)
- Use isso para identificar padrões crescentes, decrescentes ou estáveis
- Compare o mês atual com a média dos 3 meses
**Análise de Recorrência:**
- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas
- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes
- Use isso para avaliar previsibilidade e oportunidades de otimização
**Gastos Parcelados:**
- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
- Use isso para alertas sobre comprometimento de renda futura
Organize suas observações nas 4 categories especificadas no prompt do sistema:
1. Comportamentos Observados (behaviors): 3-6 itens
2. Gatilhos de Consumo (triggers): 3-6 itens
3. Recomendações Práticas (recommendations): 3-6 itens
4. Melhorias Sugeridas (improvements): 3-6 itens
Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos.
Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
});
const validatedData = InsightsResponseSchema.parse(result.object);
return {
success: true,
data: validatedData,
};
} catch (error) {
console.error("Error generating insights:", error);
return {
success: false,
error: "Erro ao gerar insights. Tente novamente.",
};
}
}

View File

@@ -0,0 +1,208 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { savedInsights } from "@/db/schema";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/shared/lib/schemas/insights";
import type { ActionResult } from "./types";
const periodSchema = z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido (formato esperado: YYYY-MM)");
export async function saveInsightsAction(
period: string,
modelId: string,
data: InsightsResponse,
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
try {
const user = await getUser();
const validatedPeriod = periodSchema.safeParse(period);
if (!validatedPeriod.success) {
return {
success: false,
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido",
};
}
period = validatedPeriod.data;
const existing = await db
.select()
.from(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
if (existing.length > 0) {
const updated = await db
.update(savedInsights)
.set({
modelId,
data: JSON.stringify(data),
updatedAt: new Date(),
})
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.returning({
id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const updatedRecord = updated[0];
if (!updatedRecord) {
return {
success: false,
error: "Falha ao atualizar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: updatedRecord.id,
createdAt: updatedRecord.createdAt,
},
};
}
const result = await db
.insert(savedInsights)
.values({
userId: user.id,
period,
modelId,
data: JSON.stringify(data),
})
.returning({
id: savedInsights.id,
createdAt: savedInsights.createdAt,
});
const insertedRecord = result[0];
if (!insertedRecord) {
return {
success: false,
error: "Falha ao salvar a análise. Tente novamente.",
};
}
return {
success: true,
data: {
id: insertedRecord.id,
createdAt: insertedRecord.createdAt,
},
};
} catch (error) {
console.error("Error saving insights:", error);
return {
success: false,
error: "Erro ao salvar análise. Tente novamente.",
};
}
}
export async function loadSavedInsightsAction(period: string): Promise<
ActionResult<{
insights: InsightsResponse;
modelId: string;
createdAt: Date;
} | null>
> {
try {
const user = await getUser();
const validatedPeriod = periodSchema.safeParse(period);
if (!validatedPeriod.success) {
return {
success: false,
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido",
};
}
period = validatedPeriod.data;
const result = await db
.select()
.from(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
)
.limit(1);
if (result.length === 0) {
return {
success: true,
data: null,
};
}
const saved = result[0];
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
return {
success: true,
data: {
insights,
modelId: saved.modelId,
createdAt: saved.createdAt,
},
};
} catch (error) {
console.error("Error loading saved insights:", error);
return {
success: false,
error: "Erro ao carregar análise salva. Tente novamente.",
};
}
}
export async function deleteSavedInsightsAction(
period: string,
): Promise<ActionResult<void>> {
try {
const user = await getUser();
const validatedPeriod = periodSchema.safeParse(period);
if (!validatedPeriod.success) {
return {
success: false,
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido",
};
}
period = validatedPeriod.data;
await db
.delete(savedInsights)
.where(
and(
eq(savedInsights.userId, user.id),
eq(savedInsights.period, period),
),
);
return {
success: true,
data: undefined,
};
} catch (error) {
console.error("Error deleting saved insights:", error);
return {
success: false,
error: "Erro ao remover análise. Tente novamente.",
};
}
}

View File

@@ -0,0 +1,3 @@
export type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };

View File

@@ -157,7 +157,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
<Button
onClick={handleAnalyze}
disabled={isPending || isLoading}
className="bg-linear-to-r from-primary to-violet-500 dark:from-primary-dark dark:to-emerald-600"
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
{isPending ? "Analisando..." : "Gerar análise inteligente"}

View File

@@ -33,61 +33,47 @@ export const PROVIDERS = {
* Lista de modelos de IA disponíveis para análise de insights
*/
export const AVAILABLE_MODELS = [
// OpenAI Models - GPT-5.2 Family (Latest)
{ id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const },
{
id: "gpt-5.2-instant",
name: "GPT-5.2 Instant",
provider: "openai" as const,
},
{
id: "gpt-5.2-thinking",
name: "GPT-5.2 Thinking",
provider: "openai" as const,
},
// OpenAI
{ id: "gpt-5.4", name: "GPT-5.4", provider: "openai" as const },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", provider: "openai" as const },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", provider: "openai" as const },
// OpenAI Models - GPT-5 Family
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
{ id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const },
// Anthropic Models - Claude 4.5
// Anthropic
{
id: "claude-4.5-haiku",
name: "Claude 4.5 Haiku",
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
provider: "anthropic" as const,
},
{
id: "claude-4.5-sonnet",
name: "Claude 4.5 Sonnet",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic" as const,
},
{
id: "claude-opus-4.1",
name: "Claude 4.1 Opus",
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
provider: "anthropic" as const,
},
// Google Models - Gemini 3 (Latest)
// Google
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
provider: "google" as const,
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash",
provider: "google" as const,
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro",
provider: "google" as const,
},
// Google Models - Gemini 2.0
{
id: "gemini-2.0-flash",
name: "Gemini 2.0 Flash",
id: "gemini-3.1-flash-lite-preview",
name: "Gemini 3.1 Flash Lite",
provider: "google" as const,
},
] as const;
export const DEFAULT_MODEL = "gpt-5.2";
export const DEFAULT_MODEL = "gpt-5.4";
export const DEFAULT_PROVIDER = "openai";
/**