feat: general improvements
This commit is contained in:
@@ -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=
|
||||
|
||||
14
README.md
14
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "reflections" JSONB;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ALTER COLUMN "reflections" SET DEFAULT 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"
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
74
src/persona/prompt.ts
Normal file
74
src/persona/prompt.ts
Normal file
@@ -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:
|
||||
|
||||
<persona_seed>
|
||||
<name_letters>${letters}</name_letters>
|
||||
<birth_year>${year}</birth_year>
|
||||
<postal_code>${postalCode}</postal_code>
|
||||
</persona_seed>
|
||||
|
||||
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 <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 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 <persona_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 persona creation.`;
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<typeof exerciseActivitySchema>;
|
||||
|
||||
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<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 personaSchema = z.object({
|
||||
core: coreSchema,
|
||||
routines: routinesSchema,
|
||||
preferences: preferencesSchema,
|
||||
finances: financesSchema,
|
||||
habits: habitsSchema,
|
||||
context: contextSchema
|
||||
});
|
||||
|
||||
export type Persona = z.infer<typeof personaSchema>;
|
||||
|
||||
296
src/purchase/prompt.ts
Normal file
296
src/purchase/prompt.ts
Normal file
@@ -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<string> {
|
||||
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`;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
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) {
|
||||
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 personaPrompt = await generatePurchasePrompt(
|
||||
const personaPrompt = await generatePrompt(
|
||||
persona,
|
||||
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50')
|
||||
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
|
||||
date,
|
||||
numWeeks
|
||||
);
|
||||
|
||||
const result = (await makeRequest(
|
||||
@@ -26,49 +30,53 @@ export async function generate(personaId: number) {
|
||||
Tool as BaseTool
|
||||
)) as PurchaseList;
|
||||
|
||||
await saveToDb(personaId, result);
|
||||
const validPurchases = purchaseListSchema.safeParse(result);
|
||||
|
||||
await saveToJson(result, personaId);
|
||||
if (validPurchases.error) {
|
||||
throw Error(`Invalid purchases: ${validPurchases.error.message}`);
|
||||
}
|
||||
|
||||
console.log(`Generated ${result.items.length} purchases`);
|
||||
await saveToJson(validPurchases.data, personaId);
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
export const Tool = {
|
||||
name: 'PurchaseSchema' as const,
|
||||
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'
|
||||
description: 'Name of the purchased item or service'
|
||||
},
|
||||
amount: {
|
||||
type: 'number' as const,
|
||||
@@ -25,14 +44,22 @@ export const Tool = {
|
||||
type: 'string' as const,
|
||||
description: 'Purchase location'
|
||||
},
|
||||
notes: {
|
||||
category: {
|
||||
type: 'string' as const,
|
||||
description: 'Additional purchase details (optional)'
|
||||
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:
|
||||
'Array of reflections on purchases over threshold amount',
|
||||
description: 'Reflections for purchases over threshold',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
@@ -48,22 +75,67 @@ export const Tool = {
|
||||
},
|
||||
date: {
|
||||
type: 'string' as const,
|
||||
description: 'Date of the reflection in ISO 8601 format',
|
||||
description: 'Reflection date in ISO 8601 format',
|
||||
format: 'date-time'
|
||||
},
|
||||
mood: {
|
||||
type: 'string' as const,
|
||||
description: 'Optional context about mood during reflection'
|
||||
description: 'Mood during reflection'
|
||||
},
|
||||
relatedTo: {
|
||||
type: 'string' as const,
|
||||
description: 'Optional related event or context'
|
||||
}
|
||||
},
|
||||
required: ['comment', 'satisfactionScore', 'date'] as const
|
||||
required: [
|
||||
'comment',
|
||||
'satisfactionScore',
|
||||
'date',
|
||||
'mood'
|
||||
] as const
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['name', 'amount', 'datetime', 'location'] 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: ['items'] as const
|
||||
required: ['weekNumber', 'startDate', 'endDate', 'purchases'] as const
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['weeks'] as const
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -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<typeof purchaseListSchema>;
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await anthropic.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 8192,
|
||||
@@ -42,4 +43,7 @@ export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
|
||||
];
|
||||
|
||||
return content[1].input;
|
||||
} catch (error) {
|
||||
throw Error('Anthropic client error.');
|
||||
}
|
||||
}
|
||||
|
||||
33
src/utils/dateFunctions.ts
Normal file
33
src/utils/dateFunctions.ts
Normal 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;
|
||||
}
|
||||
@@ -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}.`;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
87
yarn.lock
87
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<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>":
|
||||
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>":
|
||||
version: 2.3.3
|
||||
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::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
|
||||
|
||||
Reference in New Issue
Block a user