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

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
});