feat: convert to nextjs

This commit is contained in:
2024-12-07 07:45:24 +01:00
parent b248ee80ee
commit 633b8ee207
52 changed files with 4121 additions and 982 deletions

47
utils/anthropicClient.ts Normal file
View File

@@ -0,0 +1,47 @@
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
export interface BaseTool {
readonly name: string;
readonly input_schema: {
readonly type: 'object';
readonly properties: Record<string, unknown>;
readonly required?: readonly string[];
readonly description?: string;
};
}
export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
if (!process.env.ANTHROPIC_API_KEY) {
throw Error('No Anthropic API key found.');
}
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
try {
const response = await client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8192,
temperature: 1,
tools: [tool],
messages: [{ role: 'user', content: prompt }]
});
if (response.stop_reason && response.stop_reason !== 'tool_use') {
throw Error(JSON.stringify(response));
}
if (response.content.length != 2) {
throw Error(JSON.stringify(response));
}
const content = response.content as [
{ type: string; text: string },
{ type: string; input: object }
];
return content[1].input;
} catch (error) {
throw Error('Anthropic client error.');
}
}

75
utils/consumer/prompt.ts Normal file
View File

@@ -0,0 +1,75 @@
import { Moment } from 'moment';
export function generatePrompt(
letters: string,
birthday: Moment,
zipCode: string
): string {
return `You are tasked with creating a detailed consumer of an Italian individual based on the following seed information:
<consumer_seed>
<name_letters>${letters}</name_letters>
<birthday>${birthday.format('YYYY-MM-DD')}</birthday>
<zip_code>${zipCode}</zip_code>
</consumer_seed>
Your goal is to generate a realistic and diverse consumer that reflects the complexity of Italian society. Follow these steps to create the consumer:
1. Analyze the demographic data:
- Create a full name that includes ALL the letters provided in <name_letters>, though it may contain additional letters.
- Consider the implications of the birth year and postal code on the person's background and lifestyle.
2. Generate core demographics:
- Name and age (derived from the seed information)
- Occupation (including title, level, approximate income, location, and schedule)
- Home situation (residence type, ownership status, location)
- Household (relationship status, family members, pets if any)
3. Describe daily patterns:
- Detailed weekday schedule with approximate times and locations
- Typical weekend activities
- Commute details (transportation method, route, regular stops if applicable)
4. Define preferences and behaviors:
- Financial management style
- Brand relationships (with loyalty scores from 1 to 10)
- Preferred payment methods
5. Outline a financial profile:
- Fixed monthly expenses:
* Housing: rent/mortgage payments
* Utilities: electricity, gas, water, waste management
* Internet and phone services
* Insurance payments (home, car, health, life)
* Property taxes (if applicable)
- Regular subscriptions:
* Digital services (streaming, apps, etc.)
* Memberships (gym, clubs, etc.)
* Regular services (cleaning, maintenance, etc.)
- Category-specific spending patterns
- Impulse buying tendency (score from 1 to 10)
6. Describe regular activities:
- Exercise routines or lack thereof
- Social activities
7. Add consumerl context:
- Key stress triggers
- Reward behaviors
- Upcoming significant events
Throughout this process, consider the following:
- Ensure a diverse representation of technological aptitudes, not focusing solely on tech-savvy individuals.
- Use inclusive language and avoid stereotypes or discriminatory assumptions.
- Align numerical scores (1-10) and monetary values (in EUR) with:
a) Regional economic indicators
b) Generational trends
c) Professional sector norms
d) Local cost of living
Before providing the final consumer, wrap your analysis in <consumer_creation_process> tags. For each major section:
1. Break down the postal code implications on the person's background and lifestyle.
2. Consider multiple options for each aspect (at least 2-3 choices).
3. Explain your reasoning for the final choice.
This will help ensure a thorough and well-reasoned consumer creation.`;
}

54
utils/consumer/store.ts Normal file
View File

@@ -0,0 +1,54 @@
import 'dotenv/config';
import { Consumer, consumerSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../anthropicClient';
import { generatePrompt } from './prompt';
import { generateConsumerSeed } from '@utils/generateConsumerSeed';
import prisma from '../../prisma/prisma';
export async function generate() {
const { letters, birthday, zipCode } = generateConsumerSeed();
const newConsumer = await prisma.consumer.create({
data: {
letters,
birthday: birthday.toDate(),
zipCode
}
});
console.info(`New consumer being generated with id ${newConsumer.id}`);
const prompt = generatePrompt(letters, birthday, zipCode);
try {
const result = (await makeRequest(prompt, Tool as BaseTool)) as Consumer;
const validConsumer = consumerSchema.safeParse(result);
if (validConsumer.error) {
throw Error(`Invalid consumer generated: ${validConsumer.error.message}`);
}
console.info('Generated consumer by Anthropic', validConsumer.data);
await prisma.consumer.update({
where: {
id: newConsumer.id
},
data: {
value: validConsumer.data
}
});
console.info(`Consumer with id ${newConsumer.id} stored in database.`);
return {
id: newConsumer.id,
consumer: validConsumer.data
};
} catch (error) {
console.error('Error generating purchases:', error);
throw error;
}
}

408
utils/consumer/tool.ts Normal file
View File

@@ -0,0 +1,408 @@
export const Tool = {
name: 'ConsumerSchema' as const,
input_schema: {
type: 'object' as const,
description: 'User consumer',
properties: {
core: {
type: 'object' as const,
description: 'Core user information and demographics',
properties: {
age: {
type: 'number' as const,
description: "User's age in years"
},
name: {
type: 'string' as const,
description: "User's full name"
},
occupation: {
type: 'object' as const,
description: 'Employment details',
properties: {
title: {
type: 'string' as const,
description: 'Job title'
},
level: {
type: 'string' as const,
description: 'Career level (e.g., entry, senior, manager)'
},
income: {
type: 'number' as const,
description: 'Annual income'
},
location: {
type: 'string' as const,
description: 'Work location'
},
schedule: {
type: 'array' as const,
description: 'Working days/hours',
items: {
type: 'string' as const
}
}
}
},
home: {
type: 'object' as const,
description: 'Housing information',
properties: {
type: {
type: 'string' as const,
description: 'Type of residence (e.g., apartment, house)'
},
ownership: {
type: 'string' as const,
description: 'Ownership status (e.g., owned, rented)'
},
location: {
type: 'string' as const,
description: 'Home address or area'
},
commute_distance_km: {
type: 'number' as const,
description: 'Distance to work in kilometers'
}
}
},
household: {
type: 'object' as const,
description: 'Household composition',
properties: {
status: {
type: 'string' as const,
description: 'Marital/living status'
},
members: {
type: 'array' as const,
description: 'Other household members',
items: {
type: 'string' as const
}
},
pets: {
type: 'array' as const,
description: 'Household pets',
items: {
type: 'object' as const,
properties: {
type: {
type: 'string' as const,
description: 'Type of pet'
},
name: {
type: 'string' as const,
description: "Pet's name"
}
}
}
}
}
}
}
},
routines: {
type: 'object' as const,
description: 'Daily and weekly routines',
properties: {
weekday: {
type: 'object' as const,
description: 'Typical weekday schedule',
additionalProperties: {
type: 'object' as const,
properties: {
activity: {
type: 'string' as const,
description: 'Activity description'
},
location: {
type: 'string' as const,
description: 'Location of activity'
},
duration_minutes: {
type: 'number' as const,
description: 'Duration in minutes'
}
}
}
},
weekend: {
type: 'array' as const,
description: 'Regular weekend activities',
items: {
type: 'string' as const
}
},
commute: {
type: 'object' as const,
description: 'Commute details',
properties: {
method: {
type: 'string' as const,
description: 'Primary mode of transportation'
},
route: {
type: 'array' as const,
description: 'Regular route points',
items: {
type: 'string' as const
}
},
regular_stops: {
type: 'array' as const,
description: 'Regular stops during commute',
items: {
type: 'object' as const,
properties: {
location: {
type: 'string' as const,
description: 'Stop location'
},
purpose: {
type: 'string' as const,
description: 'Purpose of stop'
},
frequency: {
type: 'string' as const,
description: 'How often this stop is made'
}
}
}
}
}
}
}
},
preferences: {
type: 'object' as const,
description: 'User preferences and habits',
properties: {
diet: {
type: 'array' as const,
description: 'Dietary preferences and restrictions',
items: {
type: 'string' as const
}
},
brands: {
type: 'array' as const,
description: 'Brand preferences',
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
description: 'Brand name'
},
loyalty_score: {
type: 'number' as const,
description: 'Brand loyalty score (1-10)',
minimum: 1,
maximum: 10
}
}
}
},
price_sensitivity: {
type: 'number' as const,
description: 'Price sensitivity score (1-10)',
minimum: 1,
maximum: 10
},
payment_methods: {
type: 'array' as const,
description: 'Preferred payment methods',
items: {
type: 'string' as const
}
}
}
},
finances: {
type: 'object' as const,
description: 'Financial information',
properties: {
subscriptions: {
type: 'array' as const,
description: 'Regular subscriptions and fixed expenses',
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
description: 'Subscription or expense name'
},
amount: {
type: 'number' as const,
description: 'Monthly cost'
},
frequency: {
type: 'string' as const,
description: 'Billing frequency'
},
next_due_date: {
type: 'string' as const,
description: 'Next payment date',
format: 'date'
},
category: {
type: 'string' as const,
description: 'Expense category',
enum: [
'housing',
'utilities',
'insurance',
'services',
'memberships',
'digital',
'taxes',
'other'
]
},
is_fixed_expense: {
type: 'boolean' as const,
description:
'Whether this is a fixed expense (utilities, rent) or optional subscription'
},
auto_payment: {
type: 'boolean' as const,
description: 'Whether payment is automated'
}
},
required: [
'name',
'amount',
'frequency',
'next_due_date',
'category',
'is_fixed_expense',
'auto_payment'
]
}
},
spending_patterns: {
type: 'object' as const,
description: 'Spending behavior',
properties: {
impulsive_score: {
type: 'number' as const,
description: 'Impulse buying tendency (1-10)',
minimum: 1,
maximum: 10
},
categories: {
type: 'object' as const,
description: 'Spending categories',
additionalProperties: {
type: 'object' as const,
properties: {
preference_score: {
type: 'number' as const,
description: 'Category preference (1-10)',
minimum: 1,
maximum: 10
},
monthly_budget: {
type: 'number' as const,
description: 'Typical monthly spend'
}
}
}
}
}
}
}
},
habits: {
type: 'object' as const,
description: 'Regular activities and habits',
properties: {
exercise: {
type: 'array' as const,
description: 'Exercise routines',
items: {
type: 'object' as const,
properties: {
activity: {
type: 'string' as const,
description: 'Type of exercise'
},
frequency: {
type: 'string' as const,
description: 'How often performed'
},
duration_minutes: {
type: 'number' as const,
description: 'Typical duration'
}
}
}
},
social: {
type: 'array' as const,
description: 'Social activities',
items: {
type: 'object' as const,
properties: {
activity: {
type: 'string' as const,
description: 'Type of social activity'
},
frequency: {
type: 'string' as const,
description: 'How often performed'
}
}
}
}
}
},
context: {
type: 'object' as const,
description: 'Contextual information',
properties: {
stress_triggers: {
type: 'array' as const,
description: 'Known stress factors',
items: {
type: 'string' as const
}
},
reward_behaviors: {
type: 'array' as const,
description: 'Activities used as rewards',
items: {
type: 'string' as const
}
},
upcoming_events: {
type: 'array' as const,
description: 'Planned future events',
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
description: 'Event name'
},
date: {
type: 'string' as const,
description: 'Event date',
format: 'date'
},
importance: {
type: 'number' as const,
description: 'Event importance (1-10)',
minimum: 1,
maximum: 10
}
}
}
}
}
}
}
}
} as const;

138
utils/consumer/types.ts Normal file
View File

@@ -0,0 +1,138 @@
import { z } from 'zod';
const petSchema = z.object({
type: z.string(),
name: z.string()
});
const regularStopSchema = z.object({
location: z.string(),
purpose: z.string(),
frequency: z.string()
});
const weekdayActivitySchema = z.object({
activity: z.string(),
location: z.string(),
duration_minutes: z.number()
});
const brandPreferenceSchema = z.object({
name: z.string(),
loyalty_score: z.number().min(1).max(10)
});
const subscriptionSchema = z.object({
name: z.string(),
amount: z.number(),
frequency: z.string(),
next_due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
category: z.enum([
'housing',
'utilities',
'insurance',
'services',
'memberships',
'digital',
'taxes',
'other'
]),
is_fixed_expense: z.boolean(),
auto_payment: z.boolean()
});
const spendingCategorySchema = z.object({
preference_score: z.number().min(1).max(10),
monthly_budget: z.number()
});
const exerciseActivitySchema = z.object({
activity: z.string(),
frequency: z.string(),
duration_minutes: z.number()
});
export type ExerciseActivity = z.infer<typeof exerciseActivitySchema>;
const socialActivitySchema = z.object({
activity: z.string(),
frequency: z.string()
});
export type SocialActivity = z.infer<typeof socialActivitySchema>;
const upcomingEventSchema = z.object({
name: z.string(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
importance: z.number().min(1).max(10)
});
const coreSchema = z.object({
age: z.number(),
name: z.string(),
occupation: z.object({
title: z.string(),
level: z.string(),
income: z.number(),
location: z.string(),
schedule: z.array(z.string())
}),
home: z.object({
type: z.string(),
ownership: z.string(),
location: z.string(),
commute_distance_km: z.number()
}),
household: z.object({
status: z.string(),
members: z.array(z.string()),
pets: z.array(petSchema)
})
});
const routinesSchema = z.object({
weekday: z.record(weekdayActivitySchema),
weekend: z.array(z.string()),
commute: z.object({
method: z.string(),
route: z.array(z.string()),
regular_stops: z.array(regularStopSchema)
})
});
const preferencesSchema = z.object({
diet: z.array(z.string()),
brands: z.array(brandPreferenceSchema),
price_sensitivity: z.number().min(1).max(10),
payment_methods: z.array(z.string())
});
const financesSchema = z.object({
subscriptions: z.array(subscriptionSchema),
spending_patterns: z.object({
impulsive_score: z.number().min(1).max(10),
categories: z.record(spendingCategorySchema)
})
});
const habitsSchema = z.object({
exercise: z.array(exerciseActivitySchema),
social: z.array(socialActivitySchema)
});
const contextSchema = z.object({
stress_triggers: z.array(z.string()),
reward_behaviors: z.array(z.string()),
upcoming_events: z.array(upcomingEventSchema)
});
export const consumerSchema = z.object({
core: coreSchema,
routines: routinesSchema,
preferences: preferencesSchema,
finances: financesSchema,
habits: habitsSchema,
context: contextSchema
});
export type Consumer = z.infer<typeof consumerSchema>;

33
utils/dateFunctions.ts Normal file
View File

@@ -0,0 +1,33 @@
export function getWeekRanges(
targetDate: Date,
numWeeks: number
): Array<{ start: Date; end: Date }> {
if (numWeeks < 1 || numWeeks > 8) {
throw new Error('Number of weeks must be between 1 and 8');
}
const ranges = [];
const firstDay = new Date(targetDate);
firstDay.setUTCHours(0, 0, 0, 0);
const dayOfWeek = firstDay.getUTCDay();
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
firstDay.setUTCDate(firstDay.getUTCDate() + diff);
for (let i = 0; i < numWeeks; i++) {
const weekStart = new Date(firstDay);
weekStart.setUTCDate(weekStart.getUTCDate() + i * 7);
const weekEnd = new Date(weekStart);
weekEnd.setUTCDate(weekEnd.getUTCDate() + 6);
weekEnd.setUTCHours(23, 59, 59, 999);
ranges.push({ start: weekStart, end: weekEnd });
}
return ranges;
}
export function isDateInRange(date: Date, start: Date, end: Date): boolean {
return date >= start && date <= end;
}

View File

@@ -0,0 +1,115 @@
import moment, { Moment } from 'moment';
const PROVINCE_CODES = [
'00', // Roma
'04', // Latina
'10', // Torino
'12', // Cuneo
'16', // Genova
'20', // Milano
'24', // Bergamo
'25', // Brescia
'30', // Venezia
'31', // Treviso
'35', // Padova
'40', // Bologna
'45', // Rovigo
'47', // Forli-Cesena
'48', // Ravenna
'50', // Firenze
'51', // Pistoia
'52', // Arezzo
'53', // Siena
'54', // Massa-Carrara
'55', // Lucca
'56', // Pisa
'57', // Livorno
'58', // Grosseto
'60', // Ancona
'61', // Pesaro
'63', // Ascoli Piceno
'65', // Pescara
'66', // Chieti
'67', // L'Aquila
'70', // Bari
'71', // Foggia
'72', // Brindisi
'73', // Lecce
'74', // Taranto
'75', // Matera
'80', // Napoli
'81', // Caserta
'82', // Benevento
'83', // Avellino
'84', // Salerno
'87', // Cosenza
'88', // Catanzaro
'89', // Reggio Calabria
'90', // Palermo
'91', // Trapani
'92', // Agrigento
'93', // Caltanissetta
'94', // Enna
'95', // Catania
'96', // Siracusa
'97', // Ragusa
'98' // Messina
];
export function generateRandomCAP(): string {
const provinceCode =
PROVINCE_CODES[Math.floor(Math.random() * PROVINCE_CODES.length)];
const lastThreeDigits = Math.floor(Math.random() * 1000)
.toString()
.padStart(3, '0');
return `${provinceCode}${lastThreeDigits}`;
}
function generateLetters(): string {
const consonants = 'BCDFGLMNPRSTVZ';
const vowels = 'AEIOU';
let result = '';
const consonantCount = 4 + Math.floor(Math.random() * 2);
for (let i = 0; i < consonantCount; i++) {
result += consonants[Math.floor(Math.random() * consonants.length)];
}
const extraVowels = 3 + Math.floor(Math.random() * 2);
for (let i = 0; i < extraVowels; i++) {
result += vowels[Math.floor(Math.random() * vowels.length)];
}
return result
.split('')
.sort(() => Math.random() - 0.5)
.join('');
}
function generateBirthYear(): Moment {
const currentYear = moment().year();
const minYear = currentYear - 50;
const maxYear = currentYear - 20;
const year = Math.floor(Math.random() * (maxYear - minYear) + minYear);
const startDate = moment([year, 0, 1]); // January 1st
const endDate = moment([year, 11, 31]); // December 31st
const startTimestamp = startDate.valueOf();
const endTimestamp = endDate.valueOf();
const randomTimestamp =
startTimestamp + Math.random() * (endTimestamp - startTimestamp);
return moment(randomTimestamp).startOf('day');
}
export function generateConsumerSeed() {
const letters = generateLetters();
const birthday = generateBirthYear();
const zipCode = generateRandomCAP();
return { letters, birthday, zipCode };
}

296
utils/purchases/prompt.ts Normal file
View File

@@ -0,0 +1,296 @@
import { ExerciseActivity, Consumer, SocialActivity } from '../consumer/types';
import { getWeekRanges, isDateInRange } from '../dateFunctions';
function formatCategories(
categories?: Record<
string,
{ preference_score: number; monthly_budget: number }
>
): string {
if (!categories || Object.keys(categories).length === 0) {
return '(No spending categories defined)';
}
return Object.entries(categories)
.map(([category, data]) => {
const weeklyBudget = Math.round(data.monthly_budget / 4.33);
const preferenceLevel =
data.preference_score >= 8
? 'high'
: data.preference_score >= 5
? 'moderate'
: 'low';
return `- ${category}: €${weeklyBudget}/week (${preferenceLevel} preference)`;
})
.join('\n');
}
function formatHabits(habits: {
exercise: ExerciseActivity[];
social: SocialActivity[];
}): string {
const sections: string[] = [];
if (habits.exercise?.length) {
sections.push(
'Activity Pattern:\n' +
habits.exercise
.map(
ex =>
`- ${ex.frequency} ${ex.activity} sessions (${ex.duration_minutes}min)`
)
.join('\n')
);
}
if (habits.social?.length) {
sections.push(
'Social Pattern:\n' +
habits.social
.map(soc => `- ${soc.frequency} ${soc.activity}`)
.join('\n')
);
}
return sections.join('\n\n');
}
function formatContext(context: {
stress_triggers: string[];
reward_behaviors: string[];
upcoming_events: Array<{ name: string; date: string; importance: number }>;
}): string {
const sections: string[] = [];
if (context.upcoming_events?.length) {
sections.push(
'Key Events:\n' +
context.upcoming_events
.map(event => {
const impact =
event.importance >= 8
? 'significant'
: event.importance >= 5
? 'moderate'
: 'minor';
return `- ${event.name} (${event.date}) - Priority: ${event.importance}/10\n Expected spending impact: ${impact}`;
})
.join('\n')
);
}
if (context.stress_triggers?.length) {
sections.push(
'Stress Factors:\n' +
context.stress_triggers.map(trigger => `- ${trigger}`).join('\n')
);
}
if (context.reward_behaviors?.length) {
sections.push(
'Reward Activities:\n' +
context.reward_behaviors.map(behavior => `- ${behavior}`).join('\n')
);
}
return sections.join('\n\n');
}
function formatConsumerCore(core: Consumer['core']): string {
const sections = [
`${core.name} is a ${core.age}-year-old ${core.occupation.title} at ${core.occupation.location}`,
`Living: ${core.household.status}, ${core.home.ownership} ${core.home.type} in ${core.home.location}`,
formatHousehold(core.household)
];
return sections.filter(Boolean).join('\n');
}
function formatHousehold(household: Consumer['core']['household']): string {
const sections = [];
if (household.members.length) {
sections.push(`Household Members: ${household.members.join(', ')}`);
}
if (household.pets.length) {
sections.push(
`Pets: ${household.pets.map(pet => `${pet.name} (${pet.type})`).join(', ')}`
);
}
return sections.join('\n');
}
function formatDailySchedule(
weekday: Record<
string,
{
activity: string;
location: string;
duration_minutes: number;
}
>
): string {
return Object.entries(weekday)
.map(
([time, details]) =>
`- ${time}: ${details.activity} (${details.location})`
)
.join('\n');
}
function formatCommute(commute: Consumer['routines']['commute']): string {
return `${commute.method} (${commute.route.join(' → ')})
Regular stops: ${commute.regular_stops
.map(stop => `${stop.frequency} ${stop.purpose} at ${stop.location}`)
.join(', ')}`;
}
function formatPurchasingStyle(impulsiveScore: number): string {
if (impulsiveScore <= 3) return 'Highly planned, rarely impulsive';
if (impulsiveScore <= 5)
return 'Mostly planned, occasional impulse purchases';
if (impulsiveScore <= 7) return 'Balance of planned and impulse purchases';
return 'Frequently makes impulse purchases';
}
function formatSubscriptions(
subscriptions: Consumer['finances']['subscriptions']
): string {
return subscriptions
.map(
sub =>
`- ${sub.name}: €${sub.amount} (${sub.frequency})${sub.auto_payment ? ' [automatic]' : ''}`
)
.join('\n');
}
function formatBrands(brands: Consumer['preferences']['brands']): string {
return brands
.map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`)
.join(', ');
}
function formatWeekSection(
range: { start: Date; end: Date },
weekNum: number,
consumer: Consumer
): string {
return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} ===
Context:
${formatWeekContext(range, consumer)}
${formatPurchaseOpportunities(consumer)}`;
}
function formatWeekContext(
range: { start: Date; end: Date },
consumer: Consumer
): string {
const contexts = [];
if (range.start.getDate() <= 5) {
contexts.push('Post-salary period');
}
const events = consumer.context.upcoming_events.filter(event =>
isDateInRange(new Date(event.date), range.start, range.end)
);
if (events.length) {
contexts.push(`Events: ${events.map(e => e.name).join(', ')}`);
}
return contexts.map(c => `- ${c}`).join('\n');
}
function formatPurchaseOpportunities(consumer: Consumer): string {
const opportunities = [];
if (consumer.routines.commute.regular_stops.length) {
opportunities.push('Regular purchase points:');
consumer.routines.commute.regular_stops.forEach(stop => {
opportunities.push(
`- ${stop.frequency} ${stop.purpose} at ${stop.location}`
);
});
}
if (consumer.habits.exercise.length) {
opportunities.push('Activity-related purchases:');
consumer.habits.exercise.forEach(ex => {
opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`);
});
}
if (consumer.habits.social.length) {
opportunities.push('Social spending occasions:');
consumer.habits.social.forEach(soc => {
opportunities.push(`- ${soc.frequency} ${soc.activity}`);
});
}
return opportunities.join('\n');
}
export async function generatePrompt(
consumer: Consumer,
reflectionThreshold: number,
targetDate: Date,
numWeeks: number
): Promise<string> {
const weekRanges = getWeekRanges(targetDate, numWeeks);
return `consumer PROFILE:
${formatConsumerCore(consumer.core)}
Daily Schedule:
${formatDailySchedule(consumer.routines.weekday)}
Commute: ${formatCommute(consumer.routines.commute)}
Weekend Activities:
${consumer.routines.weekend.map(activity => `- ${activity}`).join('\n')}
${formatHabits(consumer.habits)}
Financial Profile:
- Income: €${consumer.core.occupation.income.toLocaleString()}/year
- Payment Methods: ${consumer.preferences.payment_methods.join(', ')}
- Price Sensitivity: ${consumer.preferences.price_sensitivity}/10
- Purchasing Style: ${formatPurchasingStyle(consumer.finances.spending_patterns.impulsive_score)}
Monthly Fixed Expenses:
${formatSubscriptions(consumer.finances.subscriptions)}
Spending Categories:
${formatCategories(consumer.finances.spending_patterns.categories)}
Brand Preferences:
${formatBrands(consumer.preferences.brands)}
Dietary Preferences:
${consumer.preferences.diet.join(', ')}
${formatContext(consumer.context)}
PURCHASE GENERATION GUIDELINES:
Generate ${numWeeks} weeks of purchases for ${consumer.core.name}:
${weekRanges
.map((range, i) => formatWeekSection(range, i + 1, consumer))
.join('\n\n')}
Purchase Format:
[DATE] [TIME] | [LOCATION] | [ITEMS] | €[AMOUNT] | [CATEGORY] | [PLANNED/IMPULSE]
Reflection Guidelines (for purchases over €${reflectionThreshold}):
- Consider purchase significance and context
- Reference lifestyle impact and usage patterns
- Include relation to events or stress factors
- Account for social context where relevant
Reflection Format:
[DATE] | Mood: [MOOD] | [REFLECTION] | Satisfaction: [SCORE]/10`;
}

83
utils/purchases/store.ts Normal file
View File

@@ -0,0 +1,83 @@
import { PurchaseList, purchaseListSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../anthropicClient';
import { generatePrompt } from './prompt';
import { Consumer } from '../consumer/types';
import prisma from '../../prisma/prisma';
export async function generate(
id: number | undefined,
editedConsumer: Consumer,
date: Date
) {
const consumerPrompt = await generatePrompt(
editedConsumer,
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
date,
parseInt(process.env.NUMBER_OF_WEEKS ?? '4')
);
const consumer = id
? await prisma.consumer.update({
where: {
id
},
data: {
editedValue: editedConsumer
}
})
: await prisma.consumer.create({
data: {
editedValue: editedConsumer
}
});
const newPurchaseList = await prisma.purchaseList.create({
data: {
consumerId: consumer.id
}
});
console.info(
`Generating purchase list with id ${newPurchaseList.id} for consumer with id ${consumer.id}`
);
try {
const result = (await makeRequest(
consumerPrompt,
Tool as BaseTool
)) as PurchaseList;
const validPurchases = purchaseListSchema.safeParse(result);
if (validPurchases.error) {
throw Error(`Invalid purchases: ${validPurchases.error.message}`);
}
const totalPurchases = validPurchases.data.weeks.reduce(
(acc, week) => acc + week.purchases.length,
0
);
console.info(
`Generated ${totalPurchases} purchases for purchase list with id ${newPurchaseList.id} for consumer wth id ${id}`
);
await prisma.purchaseList.update({
where: {
id: newPurchaseList.id
},
data: {
value: validPurchases.data
}
});
console.info(
`Purchase list with id ${newPurchaseList.id} for consumer with id ${id} stored in database.`
);
return validPurchases.data;
} catch (error) {
console.error('Error generating purchases:', error);
throw error;
}
}

141
utils/purchases/tool.ts Normal file
View File

@@ -0,0 +1,141 @@
export const Tool = {
name: 'PurchasesSchema' as const,
input_schema: {
type: 'object' as const,
properties: {
weeks: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
weekNumber: {
type: 'number' as const,
description: 'Sequential week number starting from 1'
},
startDate: {
type: 'string' as const,
description: 'Start date of the week in ISO 8601 format',
format: 'date'
},
endDate: {
type: 'string' as const,
description: 'End date of the week in ISO 8601 format',
format: 'date'
},
purchases: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
description: 'Name of the purchased item or service'
},
amount: {
type: 'number' as const,
description: 'Purchase amount in EUR'
},
datetime: {
type: 'string' as const,
description: 'Purchase date and time in ISO 8601 format',
format: 'date-time'
},
location: {
type: 'string' as const,
description: 'Purchase location'
},
category: {
type: 'string' as const,
description:
'Spending category (must match consumer preferences)'
},
isPlanned: {
type: 'boolean' as const,
description: 'Whether the purchase was planned or impulse'
},
context: {
type: 'string' as const,
description: 'Optional context about purchase circumstances'
},
reflections: {
type: 'array' as const,
description: 'Reflections for purchases over threshold',
items: {
type: 'object' as const,
properties: {
comment: {
type: 'string' as const,
description: 'Reflective comment about the purchase'
},
satisfactionScore: {
type: 'number' as const,
description: 'Purchase satisfaction score (1-10)',
minimum: 1,
maximum: 10
},
date: {
type: 'string' as const,
description: 'Reflection date in ISO 8601 format',
format: 'date-time'
},
mood: {
type: 'string' as const,
description: 'Mood during reflection'
},
relatedTo: {
type: 'string' as const,
description: 'Optional related event or context'
}
},
required: [
'comment',
'satisfactionScore',
'date',
'mood'
] as const
}
}
},
required: [
'name',
'amount',
'datetime',
'location',
'category',
'isPlanned'
] as const
},
minItems: 12,
maxItems: 20,
description: 'List of purchases for this week'
},
weekContext: {
type: 'object' as const,
properties: {
events: {
type: 'array' as const,
items: {
type: 'string' as const
},
description: 'Notable events during this week'
},
stressLevel: {
type: 'number' as const,
minimum: 1,
maximum: 10,
description: 'Overall stress level for the week'
},
notes: {
type: 'string' as const,
description: 'Additional context about the week'
}
}
}
},
required: ['weekNumber', 'startDate', 'endDate', 'purchases'] as const
}
}
},
required: ['weeks'] as const
}
} as const;

86
utils/purchases/types.ts Normal file
View File

@@ -0,0 +1,86 @@
import { consumerSchema } from '@utils/consumer/types';
import { z } from 'zod';
const isoDateTimeString = z.string().refine(
value => {
try {
return !isNaN(new Date(value).getTime());
} catch {
return false;
}
},
{ message: 'Invalid ISO 8601 datetime format' }
);
const reflectionSchema = z.object({
comment: z.string().min(1, 'Comment is required'),
satisfactionScore: z.number().min(1).max(10),
date: isoDateTimeString,
mood: z.string().nullable().optional(),
relatedTo: z.string().optional()
});
const weekContextSchema = z.object({
events: z.array(z.string()).optional(),
stressLevel: z.number().min(1).max(10).optional(),
notes: z.string().optional()
});
const purchaseSchema = z.object({
name: z.string().min(1, 'Name is required'),
amount: z.number().positive('Amount must be positive'),
datetime: isoDateTimeString,
location: z.string().min(1, 'Location is required'),
category: z.string().min(1, 'Category is required'),
isPlanned: z.boolean(),
context: z.string().optional(),
notes: z.string().optional(),
reflections: z.array(reflectionSchema).optional()
});
const weekSchema = z
.object({
weekNumber: z.number().positive().int(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
purchases: z
.array(purchaseSchema)
.min(7, 'Minimum 7 purchases required per week')
.max(21, 'Maximum 21 purchases allowed per week'),
weekContext: weekContextSchema.optional()
})
.refine(
data => {
const start = new Date(data.startDate);
const end = new Date(data.endDate);
return start <= end;
},
{
message: 'End date must be after or equal to start date',
path: ['endDate']
}
);
export const purchaseListSchema = z
.object({
weeks: z.array(weekSchema).min(1, 'At least one week is required')
})
.refine(
data => {
const weekNumbers = data.weeks
.map(w => w.weekNumber)
.sort((a, b) => a - b);
return weekNumbers.every((num, idx) => num === idx + 1);
},
{
message: 'Week numbers must be sequential starting from 1',
path: ['weeks']
}
);
export type PurchaseList = z.infer<typeof purchaseListSchema>;
export const purchasesRequestSchema = z.object({
id: z.number().optional(),
consumer: consumerSchema
});

27
utils/rateLimiter.ts Normal file
View File

@@ -0,0 +1,27 @@
import prisma from '../prisma/prisma';
export async function rateLimiter() {
if (!process.env.RATE_LIMIT) {
throw Error('Rate limit missing.');
}
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const consumersCount = await prisma.consumer.count({
where: {
createdAt: {
gt: yesterday
}
}
});
const purchaseListsCount = await prisma.purchaseList.count({
where: {
createdAt: {
gt: yesterday
}
}
});
return consumersCount + purchaseListsCount > parseInt(process.env.RATE_LIMIT);
}