feat: convert to nextjs
This commit is contained in:
296
utils/purchases/prompt.ts
Normal file
296
utils/purchases/prompt.ts
Normal 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
83
utils/purchases/store.ts
Normal 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
141
utils/purchases/tool.ts
Normal 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
86
utils/purchases/types.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user