diff --git a/.env.example b/.env.example
index f5c12de..2242f36 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,3 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/persona?schema=public&connect_timeout=300"
-PERSONA_PROMPT=
PURCHASE_REFLECTION_THRESHOLD=50
ANTHROPIC_API_KEY=
diff --git a/README.md b/README.md
index ccf1303..b0f9a2b 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,7 @@ A TypeScript application that leverages the Anthropic Claude API to generate rea
- Financial patterns and spending habits
- Contextual behaviors and upcoming events
- Create realistic weekly purchase histories that match persona profiles
-- Store generated data in:
- - PostgreSQL database for structured querying
- - JSON files for easy data portability
+- Store generated data in JSON files for easy data portability
## 🚀 Getting Started
@@ -29,13 +27,7 @@ yarn install
cp .env.example .env
```
-3. Initialize the database:
-
-```bash
-yarn migrate
-```
-
-4. Build and start the application:
+3. Build and start the application:
```bash
yarn build
@@ -56,8 +48,6 @@ yarn dev
- `yarn lint` - Run ESLint with automatic fixes
- `yarn format` - Format code using Prettier
- `yarn typecheck` - Check TypeScript types
-- `yarn generate` - Generate Prisma client
-- `yarn migrate` - Run database migrations
## ⚠️ Disclaimer
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index ec5110c..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-version: "3.8"
-
-services:
- postgres:
- container_name: personadb
- image: postgres:15
- environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: persona
- ports:
- - "5433:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
-
-volumes:
- postgres_data:
diff --git a/package.json b/package.json
index 4c03add..34a781c 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,9 @@
{
"name": "purchases-personas",
- "version": "1.0.0",
+ "version": "1.1.0",
"description": "Generate realistic fictional personas and their weekly purchase behaviors using AI",
"scripts": {
- "start": "prisma generate && node dist/index.js",
+ "start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc",
"lint": "eslint . --fix",
@@ -16,10 +16,10 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
- "@prisma/client": "^5.22.0",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
- "express": "^4.21.1"
+ "express": "^4.21.1",
+ "zod": "^3.23.8"
},
"devDependencies": {
"@commitlint/cli": "^18.4.3",
@@ -35,7 +35,6 @@
"lint-staged": "^15.1.0",
"nodemon": "^3.0.2",
"prettier": "^3.1.0",
- "prisma": "^5.8.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.0"
}
diff --git a/prisma/migrations/20241123210705_init/migration.sql b/prisma/migrations/20241123210705_init/migration.sql
deleted file mode 100644
index e2d3196..0000000
--- a/prisma/migrations/20241123210705_init/migration.sql
+++ /dev/null
@@ -1,29 +0,0 @@
--- CreateTable
-CREATE TABLE "users" (
- "id" SERIAL NOT NULL,
- "name" TEXT NOT NULL,
- "age" INTEGER NOT NULL,
- "persona" JSONB NOT NULL,
- "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updatedAt" TIMESTAMP(3) NOT NULL,
-
- CONSTRAINT "users_pkey" PRIMARY KEY ("id")
-);
-
--- CreateTable
-CREATE TABLE "items" (
- "id" SERIAL NOT NULL,
- "name" TEXT NOT NULL,
- "amount" DOUBLE PRECISION NOT NULL,
- "datetime" TIMESTAMP(3) NOT NULL,
- "location" TEXT NOT NULL,
- "notes" TEXT,
- "userId" INTEGER NOT NULL,
- "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updatedAt" TIMESTAMP(3) NOT NULL,
-
- CONSTRAINT "items_pkey" PRIMARY KEY ("id")
-);
-
--- AddForeignKey
-ALTER TABLE "items" ADD CONSTRAINT "items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql b/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql
deleted file mode 100644
index 4f0b823..0000000
--- a/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- AlterTable
-ALTER TABLE "items" ADD COLUMN "reflections" JSONB;
diff --git a/prisma/migrations/20241124141114_purchase_reflections_default_null/migration.sql b/prisma/migrations/20241124141114_purchase_reflections_default_null/migration.sql
deleted file mode 100644
index 56e4899..0000000
--- a/prisma/migrations/20241124141114_purchase_reflections_default_null/migration.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- AlterTable
-ALTER TABLE "items" ALTER COLUMN "reflections" SET DEFAULT null;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
deleted file mode 100644
index fbffa92..0000000
--- a/prisma/migrations/migration_lock.toml
+++ /dev/null
@@ -1,3 +0,0 @@
-# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
-provider = "postgresql"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
deleted file mode 100644
index 0e4a918..0000000
--- a/prisma/schema.prisma
+++ /dev/null
@@ -1,36 +0,0 @@
-generator client {
- provider = "prisma-client-js"
-}
-
-datasource db {
- provider = "postgresql"
- url = env("DATABASE_URL")
-}
-
-model User {
- id Int @id @default(autoincrement())
- name String
- age Int
- persona Json
- items Item[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@map("users")
-}
-
-model Item {
- id Int @id @default(autoincrement())
- name String
- amount Float
- datetime DateTime
- location String
- notes String?
- reflections Json? @default(dbgenerated("null"))
- user User @relation(fields: [userId], references: [id])
- userId Int
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@map("items")
-}
diff --git a/src/index.ts b/src/index.ts
index 68544ed..233668a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,12 +4,15 @@ import { generate as generatePurchases } from './purchase/store';
const personaPromise = generatePersona();
+const date = new Date();
+const numWeeks = 4;
+
console.log(`Generating persona...`);
personaPromise.then(id => {
console.log(`Generating purchases for id ${id}...`);
- const purchasesPromise = generatePurchases(id);
+ const purchasesPromise = generatePurchases(id, date, numWeeks);
purchasesPromise.then(() => {
console.log('Complete');
diff --git a/src/persona/prompt.ts b/src/persona/prompt.ts
new file mode 100644
index 0000000..b0263da
--- /dev/null
+++ b/src/persona/prompt.ts
@@ -0,0 +1,74 @@
+import { generatePersonaSeed } from '../utils/generatePersonaSeed';
+
+export function generatePrompt(): string {
+ const seed = generatePersonaSeed();
+ const [letters, year, postalCode] = seed.split(':');
+
+ return `You are tasked with creating a detailed persona of an Italian individual based on the following seed information:
+
+
+ ${letters}
+ ${year}
+ ${postalCode}
+
+
+ Your goal is to generate a realistic and diverse persona that reflects the complexity of Italian society. Follow these steps to create the persona:
+
+ 1. Analyze the demographic data:
+ - Create a full name that includes ALL the letters provided in , 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 personal 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 persona, wrap your analysis in 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 persona creation.`;
+}
diff --git a/src/persona/store.ts b/src/persona/store.ts
index 6034a88..f628864 100644
--- a/src/persona/store.ts
+++ b/src/persona/store.ts
@@ -1,45 +1,33 @@
import 'dotenv/config';
-import { prisma } from '../utils/prismaClient';
import fs from 'fs';
-import { Persona } from './types';
+import { Persona, personaSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder';
-import { generatePrompt } from '../utils/generatePersonaSeed';
+import { generatePrompt } from './prompt';
+import { randomUUID } from 'crypto';
export async function generate() {
- if (!process.env.PERSONA_PROMPT) {
- throw Error('Persona prompt missing.');
- }
-
const prompt = `${generatePrompt()} ${process.env.PERSONA_PROMPT}`;
const result = (await makeRequest(prompt, Tool as BaseTool)) as Persona;
- const id = await saveToDb(result);
+ const validPersona = personaSchema.safeParse(result);
- await saveToJson(result, id);
+ if (validPersona.error) {
+ throw Error(`Invalid Persona generated: ${validPersona.error.message}`);
+ }
- console.log(`Persona name: ${result.core.name}`);
+ const id = randomUUID();
+
+ await saveToJson(validPersona.data, id);
+
+ console.log(`Persona name: ${validPersona.data.core.name}`);
return id;
}
-export async function saveToDb(persona: Persona) {
- const result = await prisma.user.create({
- data: {
- name: persona.core.name,
- age: persona.core.age,
- persona: JSON.stringify(persona)
- }
- });
-
- console.log(`Persona '${result.name}' inserted in DB with id ${result.id}`);
-
- return result.id;
-}
-
-export async function saveToJson(persona: Persona, id: number) {
+export async function saveToJson(persona: Persona, id: string) {
await createFolderIfNotExists(`personas/${id}/`);
const jsonName = `personas/${id}/${id}-persona.json`;
diff --git a/src/persona/tool.ts b/src/persona/tool.ts
index 216dfef..7ad06d6 100644
--- a/src/persona/tool.ts
+++ b/src/persona/tool.ts
@@ -226,13 +226,13 @@ export const Tool = {
properties: {
subscriptions: {
type: 'array' as const,
- description: 'Regular subscriptions',
+ description: 'Regular subscriptions and fixed expenses',
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
- description: 'Subscription name'
+ description: 'Subscription or expense name'
},
amount: {
type: 'number' as const,
@@ -246,8 +246,40 @@ export const Tool = {
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: {
diff --git a/src/persona/types.ts b/src/persona/types.ts
index f566980..dbf3bd5 100644
--- a/src/persona/types.ts
+++ b/src/persona/types.ts
@@ -1,118 +1,138 @@
-interface Pet {
- [key: string]: unknown;
-}
+import { z } from 'zod';
-interface FrequencyObject {
- name: string;
- frequency: number;
-}
+const petSchema = z.object({
+ type: z.string(),
+ name: z.string()
+});
-interface SubscriptionBill {
- name: string;
- amount: number;
- date: string | Date;
-}
+const regularStopSchema = z.object({
+ location: z.string(),
+ purpose: z.string(),
+ frequency: z.string()
+});
-interface ActivityObject {
- name: string;
- frequency: number;
- schedule?: string[];
-}
+const weekdayActivitySchema = z.object({
+ activity: z.string(),
+ location: z.string(),
+ duration_minutes: z.number()
+});
-interface BrandLoyalty {
- name: string;
- loyaltyScore: number;
-}
+const brandPreferenceSchema = z.object({
+ name: z.string(),
+ loyalty_score: z.number().min(1).max(10)
+});
-interface Event {
- name: string;
- date: string | Date;
- details?: string;
-}
+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()
+});
-interface TimelineActivity {
- activity: string;
- duration: string;
- location?: string;
-}
+const spendingCategorySchema = z.object({
+ preference_score: z.number().min(1).max(10),
+ monthly_budget: z.number()
+});
-interface RegularStop {
- location: string;
- purpose: string;
- frequency: string;
-}
+const exerciseActivitySchema = z.object({
+ activity: z.string(),
+ frequency: z.string(),
+ duration_minutes: z.number()
+});
-interface SpendingCategories {
- [category: string]: {
- preference: number;
- frequency: number;
- };
-}
+export type ExerciseActivity = z.infer;
-export interface Persona {
- core: {
- age: number;
- name: string;
- occupation: {
- title: string;
- level: string;
- income: number;
- location: string;
- schedule: string[];
- };
- home: {
- type: string;
- ownership: string;
- location: string;
- commute_distance_km: number;
- };
- household: {
- status: string;
- members: string[];
- pets: Pet[];
- };
- };
- routines: {
- weekday: {
- [hour: string]: TimelineActivity;
- };
- weekend: ActivityObject[];
- commute: {
- method: string;
- route: string[];
- regular_stops: RegularStop[];
- };
- };
- preferences: {
- diet: string[];
- brands: BrandLoyalty[];
- price_sensitivity: number;
- payment_methods: string[];
- shopping: {
- grocery_stores: FrequencyObject[];
- coffee_shops: FrequencyObject[];
- restaurants: FrequencyObject[];
- retail: FrequencyObject[];
- };
- };
- finances: {
- subscriptions: SubscriptionBill[];
- regular_bills: SubscriptionBill[];
- spending_patterns: {
- impulsive_score: number;
- categories: SpendingCategories;
- };
- };
- habits: {
- exercise: ActivityObject[];
- social: ActivityObject[];
- entertainment: ActivityObject[];
- vices: ActivityObject[];
- };
- context: {
- stress_triggers: string[];
- reward_behaviors: string[];
- upcoming_events: Event[];
- recent_changes: string[];
- };
-}
+const socialActivitySchema = z.object({
+ activity: z.string(),
+ frequency: z.string()
+});
+
+export type SocialActivity = z.infer;
+
+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 personaSchema = z.object({
+ core: coreSchema,
+ routines: routinesSchema,
+ preferences: preferencesSchema,
+ finances: financesSchema,
+ habits: habitsSchema,
+ context: contextSchema
+});
+
+export type Persona = z.infer;
diff --git a/src/purchase/prompt.ts b/src/purchase/prompt.ts
new file mode 100644
index 0000000..7c200f8
--- /dev/null
+++ b/src/purchase/prompt.ts
@@ -0,0 +1,296 @@
+import { ExerciseActivity, Persona, SocialActivity } from '../persona/types';
+import { getWeekRanges, isDateInRange } from '../utils/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 formatPersonaCore(core: Persona['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: Persona['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: Persona['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: Persona['finances']['subscriptions']
+): string {
+ return subscriptions
+ .map(
+ sub =>
+ `- ${sub.name}: €${sub.amount} (${sub.frequency})${sub.auto_payment ? ' [automatic]' : ''}`
+ )
+ .join('\n');
+}
+
+function formatBrands(brands: Persona['preferences']['brands']): string {
+ return brands
+ .map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`)
+ .join(', ');
+}
+
+function formatWeekSection(
+ range: { start: Date; end: Date },
+ weekNum: number,
+ persona: Persona
+): string {
+ return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} ===
+
+Context:
+${formatWeekContext(range, persona)}
+${formatPurchaseOpportunities(persona)}`;
+}
+
+function formatWeekContext(
+ range: { start: Date; end: Date },
+ persona: Persona
+): string {
+ const contexts = [];
+
+ if (range.start.getDate() <= 5) {
+ contexts.push('Post-salary period');
+ }
+
+ const events = persona.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(persona: Persona): string {
+ const opportunities = [];
+
+ if (persona.routines.commute.regular_stops.length) {
+ opportunities.push('Regular purchase points:');
+ persona.routines.commute.regular_stops.forEach(stop => {
+ opportunities.push(
+ `- ${stop.frequency} ${stop.purpose} at ${stop.location}`
+ );
+ });
+ }
+
+ if (persona.habits.exercise.length) {
+ opportunities.push('Activity-related purchases:');
+ persona.habits.exercise.forEach(ex => {
+ opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`);
+ });
+ }
+
+ if (persona.habits.social.length) {
+ opportunities.push('Social spending occasions:');
+ persona.habits.social.forEach(soc => {
+ opportunities.push(`- ${soc.frequency} ${soc.activity}`);
+ });
+ }
+
+ return opportunities.join('\n');
+}
+
+export async function generatePrompt(
+ persona: Persona,
+ reflectionThreshold: number,
+ targetDate: Date,
+ numWeeks: number
+): Promise {
+ const weekRanges = getWeekRanges(targetDate, numWeeks);
+
+ return `PERSONA PROFILE:
+${formatPersonaCore(persona.core)}
+
+Daily Schedule:
+${formatDailySchedule(persona.routines.weekday)}
+Commute: ${formatCommute(persona.routines.commute)}
+
+Weekend Activities:
+${persona.routines.weekend.map(activity => `- ${activity}`).join('\n')}
+
+${formatHabits(persona.habits)}
+
+Financial Profile:
+- Income: €${persona.core.occupation.income.toLocaleString()}/year
+- Payment Methods: ${persona.preferences.payment_methods.join(', ')}
+- Price Sensitivity: ${persona.preferences.price_sensitivity}/10
+- Purchasing Style: ${formatPurchasingStyle(persona.finances.spending_patterns.impulsive_score)}
+
+Monthly Fixed Expenses:
+${formatSubscriptions(persona.finances.subscriptions)}
+
+Spending Categories:
+${formatCategories(persona.finances.spending_patterns.categories)}
+
+Brand Preferences:
+${formatBrands(persona.preferences.brands)}
+
+Dietary Preferences:
+${persona.preferences.diet.join(', ')}
+
+${formatContext(persona.context)}
+
+PURCHASE GENERATION GUIDELINES:
+
+Generate ${numWeeks} weeks of purchases for ${persona.core.name}:
+
+${weekRanges
+ .map((range, i) => formatWeekSection(range, i + 1, persona))
+ .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`;
+}
diff --git a/src/purchase/promptGenerator.ts b/src/purchase/promptGenerator.ts
deleted file mode 100644
index 408e317..0000000
--- a/src/purchase/promptGenerator.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import { Persona } from '../persona/types';
-
-function formatFrequencyList(
- items?: Array<{ name: string; frequency: number }>
-): string {
- if (!items?.length) return '(No data available)';
- return items
- .sort((a, b) => b.frequency - a.frequency)
- .map(item => `- ${item.name} (${item.frequency}x per month)`)
- .join('\n');
-}
-
-function formatCategories(categories?: {
- [key: string]: { preference: number; frequency: number };
-}): string {
- if (!categories || Object.keys(categories).length === 0)
- return '(No categories defined)';
- return Object.entries(categories)
- .map(([category, data]) => {
- const weeklyFrequency = Math.round(data.frequency * 4.33); // Monthly to weekly
- return `- ${category}: ${weeklyFrequency}x per week (preference: ${data.preference}/10)`;
- })
- .join('\n');
-}
-
-function formatActivities(
- activities?: Array<{ name: string; frequency: number; schedule?: string[] }>
-): string {
- if (!activities?.length) return '(No activities listed)';
- return activities
- .map(
- act =>
- `- ${act.name}: ${act.frequency}x per ${act.schedule ? act.schedule.join(', ') : 'week'}`
- )
- .join('\n');
-}
-
-function formatList(items?: string[]): string {
- if (!items?.length) return '(None listed)';
- return items.join(', ');
-}
-
-export async function generatePurchasePrompt(
- persona: Persona,
- reflectionThreshold: number
-): Promise {
- try {
- const sections: string[] = [];
-
- sections.push(`PERSONAL PROFILE:
-Name: ${persona.core.name || 'Unknown'}
-Age: ${persona.core.age || 'Unknown'}
-Occupation: ${persona.core.occupation?.title || 'Unknown'}${
- persona.core.occupation?.level
- ? ` (${persona.core.occupation.level})`
- : ''
- }
-Income: ${persona.core.occupation?.income ? `$${persona.core.occupation.income.toLocaleString()}/year` : 'Unknown'}
-Location: ${persona.core.home?.location || 'Unknown'}
-Household: ${persona.core.household?.status || 'Unknown'}${
- persona.core.household?.pets?.length
- ? `\nPets: ${persona.core.household.pets
- .map(pet => `${pet.type || 'pet'} named ${pet.name}`)
- .join(', ')}`
- : ''
- }`);
-
- if (persona.core.occupation?.schedule?.length) {
- sections.push(
- `WORK SCHEDULE:\n${persona.core.occupation.schedule.join('\n')}`
- );
- }
-
- if (persona.preferences?.shopping) {
- sections.push(`REGULAR SHOPPING PATTERNS:
-${persona.preferences.shopping.grocery_stores?.length ? `Grocery Stores:\n${formatFrequencyList(persona.preferences.shopping.grocery_stores)}` : ''}
-${persona.preferences.shopping.coffee_shops?.length ? `\nCoffee Shops:\n${formatFrequencyList(persona.preferences.shopping.coffee_shops)}` : ''}
-${persona.preferences.shopping.restaurants?.length ? `\nRestaurants:\n${formatFrequencyList(persona.preferences.shopping.restaurants)}` : ''}
-${persona.preferences.shopping.retail?.length ? `\nRetail:\n${formatFrequencyList(persona.preferences.shopping.retail)}` : ''}`);
- }
-
- if (persona.finances?.spending_patterns?.categories) {
- sections.push(
- `SPENDING CATEGORIES & FREQUENCY:\n${formatCategories(persona.finances.spending_patterns.categories)}`
- );
- }
-
- if (persona.preferences || persona.finances?.spending_patterns) {
- sections.push(`PAYMENT PREFERENCES:
-${persona.preferences?.payment_methods ? `- Methods: ${formatList(persona.preferences.payment_methods)}` : ''}
-${persona.preferences?.price_sensitivity ? `- Price Sensitivity: ${persona.preferences.price_sensitivity}/10` : ''}
-${persona.finances?.spending_patterns?.impulsive_score ? `- Impulsiveness Score: ${persona.finances.spending_patterns.impulsive_score}/10` : ''}`);
- }
-
- if (persona.routines?.commute?.regular_stops?.length) {
- sections.push(`REGULAR ROUTINES:
-Commute Stops:
-${persona.routines.commute.regular_stops
- .map(
- stop => `- ${stop.frequency} visits to ${stop.location} for ${stop.purpose}`
- )
- .join('\n')}`);
- }
-
- if (persona.preferences) {
- const preferencesSection = [`PREFERENCES:`];
- if (persona.preferences.diet?.length) {
- preferencesSection.push(
- `- Diet: ${formatList(persona.preferences.diet)}`
- );
- }
- if (persona.preferences.brands?.length) {
- preferencesSection.push(
- `- Favorite Brands: ${persona.preferences.brands
- .map(b => `${b.name} (loyalty: ${b.loyaltyScore}/10)`)
- .join(', ')}`
- );
- }
- sections.push(preferencesSection.join('\n'));
- }
-
- if (persona.habits) {
- const activitiesSection = [`REGULAR ACTIVITIES:`];
- if (persona.habits.exercise?.length) {
- activitiesSection.push(
- `Exercise:\n${formatActivities(persona.habits.exercise)}`
- );
- }
- if (persona.habits.social?.length) {
- activitiesSection.push(
- `\nSocial:\n${formatActivities(persona.habits.social)}`
- );
- }
- if (persona.habits.entertainment?.length) {
- activitiesSection.push(
- `\nEntertainment:\n${formatActivities(persona.habits.entertainment)}`
- );
- }
- sections.push(activitiesSection.join('\n'));
- }
-
- if (persona.context) {
- const contextSection = [`CONTEXT:`];
- if (persona.context.upcoming_events?.length) {
- contextSection.push(
- `Upcoming Events:\n${persona.context.upcoming_events
- .map(
- event =>
- `- ${event.name} on ${event.date}${event.details ? `: ${event.details}` : ''}`
- )
- .join('\n')}`
- );
- }
- if (persona.context.stress_triggers?.length) {
- contextSection.push(
- `\nStress Triggers: ${formatList(persona.context.stress_triggers)}`
- );
- }
- if (persona.context.reward_behaviors?.length) {
- contextSection.push(
- `\nReward Behaviors: ${formatList(persona.context.reward_behaviors)}`
- );
- }
- sections.push(contextSection.join('\n'));
- }
-
- if (persona.finances?.subscriptions?.length) {
- sections.push(`EXISTING SUBSCRIPTIONS (exclude from weekly purchases):
-${persona.finances.subscriptions.map(sub => `- ${sub.name}: $${sub.amount} (due: ${sub.date})`).join('\n')}`);
- }
-
- sections.push(`Please generate a detailed list of purchases for one week, including:
-1. Date and time of purchase
-2. Store/vendor name
-3. Items purchased
-4. Amount spent
-5. Category of spending
-6. Whether it was planned or impulse purchase
-
-Consider:
-- Regular commute stops and routines
-- Exercise and social activities
-- Dietary preferences and restrictions
-- Brand loyalties and preferred stores
-- Work schedule and regular activities
-- Price sensitivity and impulsiveness score
-- Upcoming events and potential related purchases
-${persona.context?.recent_changes?.length ? `- Recent lifestyle changes: ${formatList(persona.context.recent_changes)}` : ''}
-
-Format each purchase as:
-[DATE] [TIME] | [STORE] | [ITEMS] | $[AMOUNT] | [CATEGORY] | [PLANNED/IMPULSE]
-
-
-For purchases over €${reflectionThreshold}, randomly include 0-5 reflections following these rules:
-
-1. Each reflection must occur AFTER the purchase date
-2. Reflections must be in chronological order
-3. First reflection should typically be within a week of purchase
-4. Space out subsequent reflections realistically (e.g., weeks or months apart)
-5. No reflection should be dated after ${new Date().toISOString()}
-
-Each reflection must include:
-1. A date when the reflection was made
-2. A personal comment that makes sense for that point in time
-3. A satisfaction score from 1-10 (10 being extremely satisfied, 1 being completely regretful)
-4. The persona's mood or context when making the reflection
-
-Consider how reflection timing affects content:
-- Immediate reflections (1-7 days): Initial impressions, emotional responses
-- Short-term reflections (1-4 weeks): Early usage experience, discovering features/issues
-- Medium-term reflections (1-3 months): More balanced assessment, practical value
-- Long-term reflections (3+ months): Durability, long-term value, retrospective thoughts
-
-Factor these into reflections:
-- How the persona's view typically evolves over time
-- Seasonal or contextual factors (e.g., using winter clothes in summer)
-- Financial impact becoming more/less significant over time
-- Product durability or performance changes
-- Changes in the persona's life circumstances
-- Whether the novelty wears off or appreciation grows
-
-Format each reflection as:
-[REFLECTION_DATE] | Mood: [MOOD] | [COMMENT] | Satisfaction: [SCORE]/10
-
-Example of a purchase with reflections:
-2024-01-15 12:30 | Nike Store | Running Shoes XC90 | $180 | Clothing | PLANNED
-Reflections:
-2024-01-16 | Mood: Excited | "First run with these was amazing - the cushioning is perfect for my style" | Satisfaction: 9/10
-2024-01-30 | Mood: Focused | "After two weeks of regular runs, they're holding up great and no knee pain" | Satisfaction: 8/10
-2024-03-10 | Mood: Practical | "Three months in, still performing well but showing some wear on the sides" | Satisfaction: 8/10
-
-Generate purchases that align with the persona's lifestyle, income level, and spending patterns.`);
-
- return sections.filter(section => section.trim().length > 0).join('\n\n');
- } catch (error) {
- throw new Error(JSON.stringify(error));
- }
-}
diff --git a/src/purchase/store.ts b/src/purchase/store.ts
index c5f59bb..66d6c51 100644
--- a/src/purchase/store.ts
+++ b/src/purchase/store.ts
@@ -1,74 +1,82 @@
-import { prisma } from '../utils/prismaClient';
import fs, { readFileSync } from 'fs';
-import { PurchaseList, Reflection } from './types';
+import { PurchaseList, purchaseListSchema } from './types';
import { Tool } from './tool';
import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder';
-import { generatePurchasePrompt } from './promptGenerator';
+import { generatePrompt } from './prompt';
import { Persona } from '../persona/types';
-import { Prisma } from '@prisma/client';
-export async function generate(personaId: number) {
- const jsonFile = readFileSync(
- `personas/${personaId}/${personaId}-persona.json`,
- 'utf-8'
- );
+export async function generate(
+ personaId: string,
+ date: Date,
+ numWeeks: number
+) {
+ try {
+ const jsonFile = readFileSync(
+ `personas/${personaId}/${personaId}-persona.json`,
+ 'utf-8'
+ );
+ const persona: Persona = JSON.parse(jsonFile);
- const persona: Persona = JSON.parse(jsonFile);
+ const personaPrompt = await generatePrompt(
+ persona,
+ parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
+ date,
+ numWeeks
+ );
- const personaPrompt = await generatePurchasePrompt(
- persona,
- parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50')
- );
+ const result = (await makeRequest(
+ personaPrompt,
+ Tool as BaseTool
+ )) as PurchaseList;
- const result = (await makeRequest(
- personaPrompt,
- Tool as BaseTool
- )) as PurchaseList;
+ const validPurchases = purchaseListSchema.safeParse(result);
- await saveToDb(personaId, result);
+ if (validPurchases.error) {
+ throw Error(`Invalid purchases: ${validPurchases.error.message}`);
+ }
- await saveToJson(result, personaId);
+ await saveToJson(validPurchases.data, personaId);
- console.log(`Generated ${result.items.length} purchases`);
+ const totalPurchases = validPurchases.data.weeks.reduce(
+ (acc, week) => acc + week.purchases.length,
+ 0
+ );
+ console.log(
+ `Generated ${totalPurchases} purchases for ${persona.core.name}`
+ );
+
+ return validPurchases.data;
+ } catch (error) {
+ console.error('Error generating purchases:', error);
+ throw error;
+ }
}
-function reflectionToJson(reflection: Reflection): Prisma.JsonObject {
- return {
- comment: reflection.comment,
- satisfactionScore: reflection.satisfactionScore,
- date: reflection.date,
- mood: reflection.mood || null
- };
-}
-
-export async function saveToDb(personaId: number, purchases: PurchaseList) {
- const result = await prisma.item.createMany({
- data: purchases.items.map(
- purchase =>
- ({
- userId: personaId,
- name: purchase.name,
- amount: purchase.amount,
- datetime: purchase.datetime,
- location: purchase.location,
- notes: purchase.notes,
- reflections: purchase.reflections
- ? purchase.reflections.map(reflectionToJson)
- : Prisma.JsonNull
- }) satisfies Prisma.ItemCreateManyInput
- )
- });
-
- console.log(`Inserted ${result.count} purchases for persona ${personaId}`);
-}
-
-export async function saveToJson(purchaseList: PurchaseList, id: number) {
+export async function saveToJson(purchaseList: PurchaseList, id: string) {
await createFolderIfNotExists(`personas/${id}/`);
-
const jsonName = `personas/${id}/${id}-purchases.json`;
- await fs.promises.writeFile(jsonName, JSON.stringify(purchaseList), 'utf8');
+ await fs.promises.writeFile(
+ jsonName,
+ JSON.stringify(purchaseList, null, 2),
+ 'utf8'
+ );
- console.log(`Saved ${purchaseList.items.length} purchases as ${jsonName}`);
+ const purchaseStats = purchaseList.weeks.reduce(
+ (stats, week) => {
+ return {
+ total: stats.total + week.purchases.length,
+ planned: stats.planned + week.purchases.filter(p => p.isPlanned).length,
+ withReflections:
+ stats.withReflections +
+ week.purchases.filter(p => p.reflections?.length).length
+ };
+ },
+ { total: 0, planned: 0, withReflections: 0 }
+ );
+
+ console.log(
+ `Saved ${purchaseStats.total} purchases (${purchaseStats.planned} planned, ${purchaseStats.withReflections} with reflections) as ${jsonName}`
+ );
}
diff --git a/src/purchase/tool.ts b/src/purchase/tool.ts
index 6b35a15..2eb6013 100644
--- a/src/purchase/tool.ts
+++ b/src/purchase/tool.ts
@@ -1,69 +1,141 @@
export const Tool = {
- name: 'PurchaseSchema' as const,
+ name: 'PurchasesSchema' as const,
input_schema: {
type: 'object' as const,
properties: {
- items: {
+ weeks: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
- name: {
- type: 'string' as const,
- description: 'Name of the purchased item'
- },
- amount: {
+ weekNumber: {
type: 'number' as const,
- description: 'Purchase amount in EUR'
+ description: 'Sequential week number starting from 1'
},
- datetime: {
+ startDate: {
type: 'string' as const,
- description: 'Purchase date and time in ISO 8601 format',
- format: 'date-time'
+ description: 'Start date of the week in ISO 8601 format',
+ format: 'date'
},
- location: {
+ endDate: {
type: 'string' as const,
- description: 'Purchase location'
+ description: 'End date of the week in ISO 8601 format',
+ format: 'date'
},
- notes: {
- type: 'string' as const,
- description: 'Additional purchase details (optional)'
- },
- reflections: {
+ purchases: {
type: 'array' as const,
- description:
- 'Array of reflections on purchases over threshold amount',
items: {
type: 'object' as const,
properties: {
- comment: {
+ name: {
type: 'string' as const,
- description: 'Reflective comment about the purchase'
+ description: 'Name of the purchased item or service'
},
- satisfactionScore: {
+ amount: {
type: 'number' as const,
- description: 'Purchase satisfaction score (1-10)',
- minimum: 1,
- maximum: 10
+ description: 'Purchase amount in EUR'
},
- date: {
+ datetime: {
type: 'string' as const,
- description: 'Date of the reflection in ISO 8601 format',
+ description: 'Purchase date and time in ISO 8601 format',
format: 'date-time'
},
- mood: {
+ location: {
type: 'string' as const,
- description: 'Optional context about mood during reflection'
+ description: 'Purchase location'
+ },
+ category: {
+ type: 'string' as const,
+ description:
+ 'Spending category (must match persona 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: ['comment', 'satisfactionScore', 'date'] 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: ['name', 'amount', 'datetime', 'location'] as const
+ required: ['weekNumber', 'startDate', 'endDate', 'purchases'] as const
}
}
},
- required: ['items'] as const
+ required: ['weeks'] as const
}
} as const;
diff --git a/src/purchase/types.ts b/src/purchase/types.ts
index 1196de2..899f90a 100644
--- a/src/purchase/types.ts
+++ b/src/purchase/types.ts
@@ -1,26 +1,80 @@
-export interface Reflection {
- comment: string;
- satisfactionScore: number;
- date: string;
- mood?: string | null;
-}
+import { z } from 'zod';
-export interface Purchase {
- name: string;
- amount: number;
- datetime: string;
- location: string;
- notes?: string;
- reflections?: Reflection[];
-}
+const isoDateTimeString = z.string().refine(
+ value => {
+ try {
+ return !isNaN(new Date(value).getTime());
+ } catch {
+ return false;
+ }
+ },
+ { message: 'Invalid ISO 8601 datetime format' }
+);
-export interface PurchaseList {
- items: Purchase[];
-}
+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()
+});
-export interface ReflectionJson {
- comment: string;
- satisfactionScore: number;
- date: string;
- mood: string | null;
-}
+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(12, 'Minimum 12 purchases required per week')
+ .max(20, 'Maximum 20 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;
diff --git a/src/utils/anthropicClient.ts b/src/utils/anthropicClient.ts
index 5e63bef..5afbb48 100644
--- a/src/utils/anthropicClient.ts
+++ b/src/utils/anthropicClient.ts
@@ -20,26 +20,30 @@ export async function makeRequest(prompt: string, tool: T) {
apiKey: process.env.ANTHROPIC_API_KEY
});
- const response = await anthropic.messages.create({
- model: 'claude-3-5-sonnet-20241022',
- max_tokens: 8192,
- temperature: 1,
- tools: [tool],
- messages: [{ role: 'user', content: prompt }]
- });
+ try {
+ const response = await anthropic.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.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.');
}
-
- 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;
}
diff --git a/src/utils/dateFunctions.ts b/src/utils/dateFunctions.ts
new file mode 100644
index 0000000..e5f5d69
--- /dev/null
+++ b/src/utils/dateFunctions.ts
@@ -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;
+}
diff --git a/src/utils/generatePersonaSeed.ts b/src/utils/generatePersonaSeed.ts
index 464cb12..c1b7cb9 100644
--- a/src/utils/generatePersonaSeed.ts
+++ b/src/utils/generatePersonaSeed.ts
@@ -102,20 +102,10 @@ function formatPersonaSeed(
return `${letters}:${year}:${postalCode}`;
}
-function generatePersonaSeed(): string {
+export function generatePersonaSeed(): string {
const letters = generateLetters();
const birthYear = generateBirthYear();
const postalCode = generateRandomCAP();
return formatPersonaSeed(letters, birthYear, postalCode);
}
-
-export function generatePrompt(): string {
- const seed = generatePersonaSeed();
- const [letters, year, postalCode] = seed.split(':');
-
- return `Using the Italian persona seed ${seed}, create a detailed persona of an Italian individual where:
-- The letters "${letters}" MUST ALL be included in the person's full name (first name + last name), though the name can contain additional letters
-- The person was born in ${year}
-- They live in an area with postal code (CAP) ${postalCode}.`;
-}
diff --git a/src/utils/prismaClient.ts b/src/utils/prismaClient.ts
deleted file mode 100644
index 9b6c4ce..0000000
--- a/src/utils/prismaClient.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { PrismaClient } from '@prisma/client';
-
-export const prisma = new PrismaClient();
diff --git a/yarn.lock b/yarn.lock
index 1c3bdb4..6473b84 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -402,64 +402,6 @@ __metadata:
languageName: node
linkType: hard
-"@prisma/client@npm:^5.22.0":
- version: 5.22.0
- resolution: "@prisma/client@npm:5.22.0"
- peerDependencies:
- prisma: "*"
- peerDependenciesMeta:
- prisma:
- optional: true
- checksum: 10/991d7410ded2d6c824303ac222ae94df4f1bf42240dda97c710cfee3a3bc59fef65c73e1ee55177e38d12f4d7dfc0a047748f98b4e3026dddd65f462684cd2d1
- languageName: node
- linkType: hard
-
-"@prisma/debug@npm:5.22.0":
- version: 5.22.0
- resolution: "@prisma/debug@npm:5.22.0"
- checksum: 10/e4c425fde57f83c1a3df44d3f9088684a3e1d764022d2378f981209e57b7f421bc773d7f4fc23daa3936aa3e3772fa99fef81d2f2ec9673269f06efe822e3b53
- languageName: node
- linkType: hard
-
-"@prisma/engines-version@npm:5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2":
- version: 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
- resolution: "@prisma/engines-version@npm:5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2"
- checksum: 10/e9e3563d75482591ca482dfc26afaf8a727796f48bfcc9b550652ca9c66176f5841944d00f8a4cb60a7fd58b117d8e28b1c6b84fc7316123738f7290c91c291d
- languageName: node
- linkType: hard
-
-"@prisma/engines@npm:5.22.0":
- version: 5.22.0
- resolution: "@prisma/engines@npm:5.22.0"
- dependencies:
- "@prisma/debug": "npm:5.22.0"
- "@prisma/engines-version": "npm:5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2"
- "@prisma/fetch-engine": "npm:5.22.0"
- "@prisma/get-platform": "npm:5.22.0"
- checksum: 10/887381d8be0acc713159ef70cdc5c4b2ca5af6f3a5d2f6ae73b63c6cf31b74e5c41a9c2dbccb02b1a49bbda19a3d0a75cd1bcc86d76e98991c3a3978e61b622b
- languageName: node
- linkType: hard
-
-"@prisma/fetch-engine@npm:5.22.0":
- version: 5.22.0
- resolution: "@prisma/fetch-engine@npm:5.22.0"
- dependencies:
- "@prisma/debug": "npm:5.22.0"
- "@prisma/engines-version": "npm:5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2"
- "@prisma/get-platform": "npm:5.22.0"
- checksum: 10/512ce722e582a72429c90d438a63c730b230faa1235300215ab6cd1c5a0dfb2aa63704a66ee72c57222d9c094477ea198b1c154b9779dcab78ebf36e09f79161
- languageName: node
- linkType: hard
-
-"@prisma/get-platform@npm:5.22.0":
- version: 5.22.0
- resolution: "@prisma/get-platform@npm:5.22.0"
- dependencies:
- "@prisma/debug": "npm:5.22.0"
- checksum: 10/15db9f38933a59a1b0f3a1d6284a50261803677516d6eedec2f7caaa1767c1c6e94e6465aca9239766b1cc363002476da21f7eeab55cc5329937d6d6d57a5f67
- languageName: node
- linkType: hard
-
"@tsconfig/node10@npm:^1.0.7":
version: 1.0.11
resolution: "@tsconfig/node10@npm:1.0.11"
@@ -2085,7 +2027,7 @@ __metadata:
languageName: node
linkType: hard
-"fsevents@npm:2.3.3, fsevents@npm:~2.3.2":
+"fsevents@npm:~2.3.2":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
dependencies:
@@ -2095,7 +2037,7 @@ __metadata:
languageName: node
linkType: hard
-"fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin":
+"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"
dependencies:
@@ -3664,21 +3606,6 @@ __metadata:
languageName: node
linkType: hard
-"prisma@npm:^5.8.0":
- version: 5.22.0
- resolution: "prisma@npm:5.22.0"
- dependencies:
- "@prisma/engines": "npm:5.22.0"
- fsevents: "npm:2.3.3"
- dependenciesMeta:
- fsevents:
- optional: true
- bin:
- prisma: build/index.js
- checksum: 10/7d7a47491b6b5fb6fe23358af4ab36c1b85e837bf439c6994cfb20e98627dd15e7bc9e1be40f9620170fc587e6d0b5c6b871f44e5a380b15b7b684eb89e21037
- languageName: node
- linkType: hard
-
"proc-log@npm:^4.1.0, proc-log@npm:^4.2.0":
version: 4.2.0
resolution: "proc-log@npm:4.2.0"
@@ -3727,7 +3654,6 @@ __metadata:
"@anthropic-ai/sdk": "npm:^0.32.1"
"@commitlint/cli": "npm:^18.4.3"
"@commitlint/config-conventional": "npm:^18.4.3"
- "@prisma/client": "npm:^5.22.0"
"@types/express": "npm:^4.17.21"
"@types/node": "npm:^20.10.0"
"@typescript-eslint/eslint-plugin": "npm:^6.12.0"
@@ -3742,9 +3668,9 @@ __metadata:
lint-staged: "npm:^15.1.0"
nodemon: "npm:^3.0.2"
prettier: "npm:^3.1.0"
- prisma: "npm:^5.8.0"
ts-node: "npm:^10.9.2"
typescript: "npm:^5.3.0"
+ zod: "npm:^3.23.8"
languageName: unknown
linkType: soft
@@ -4829,3 +4755,10 @@ __metadata:
checksum: 10/f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700
languageName: node
linkType: hard
+
+"zod@npm:^3.23.8":
+ version: 3.23.8
+ resolution: "zod@npm:3.23.8"
+ checksum: 10/846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1
+ languageName: node
+ linkType: hard