From 8e7bfd20489c83c4b024712892da58be8c3f86b5 Mon Sep 17 00:00:00 2001 From: Riccardo Senica Date: Sun, 24 Nov 2024 21:31:59 +0100 Subject: [PATCH] feat: add purchase reflections --- .env.example | 2 + README.md | 65 +++++++++- package.json | 2 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 21 +-- src/index.ts | 6 +- src/persona/store.ts | 31 +++-- src/persona/types.ts | 2 +- src/purchase/promptGenerator.ts | 49 ++++++- src/purchase/store.ts | 71 ++++++---- src/purchase/tool.ts | 32 ++++- src/purchase/types.ts | 15 +++ src/utils/anthropicClient.ts | 14 +- src/utils/generatePersonaSeed.ts | 121 ++++++++++++++++++ 15 files changed, 372 insertions(+), 63 deletions(-) create mode 100644 prisma/migrations/20241124140321_add_purchase_reflections/migration.sql create mode 100644 prisma/migrations/20241124141114_purchase_reflections_default_null/migration.sql create mode 100644 src/utils/generatePersonaSeed.ts diff --git a/.env.example b/.env.example index 0ad531c..f5c12de 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ 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 a38729b..ccf1303 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# purchases-personas +# Purchases Personas Generator + +A TypeScript application that leverages the Anthropic Claude API to generate realistic fictional personas and their weekly purchase behaviors. For creating synthetic datasets for retail/e-commerce applications with believable user behaviors and spending patterns. + +## 🌟 Features + +- Generate detailed fictional personas including: + - Personal demographics and household details + - Daily routines and activities + - Shopping preferences and brand loyalties + - 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 + +## 🚀 Getting Started + +1. Install dependencies: + +```bash +yarn install +``` + +2. Set up your environment variables: + +```bash +cp .env.example .env +``` + +3. Initialize the database: + +```bash +yarn migrate +``` + +4. Build and start the application: + +```bash +yarn build +yarn start +``` + +For development: + +```bash +yarn dev +``` + +## 🛠️ Available Scripts + +- `yarn start` - Start the production server +- `yarn dev` - Start development server with hot reload +- `yarn build` - Build the TypeScript project +- `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 + +The personas and purchase histories generated by this tool are fictional and should not be used as real user data. They are intended for testing and development purposes only. diff --git a/package.json b/package.json index ca68d57..4c03add 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,7 @@ { "name": "purchases-personas", + "version": "1.0.0", + "description": "Generate realistic fictional personas and their weekly purchase behaviors using AI", "scripts": { "start": "prisma generate && node dist/index.js", "dev": "nodemon src/index.ts", diff --git a/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql b/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql new file mode 100644 index 0000000..4f0b823 --- /dev/null +++ b/prisma/migrations/20241124140321_add_purchase_reflections/migration.sql @@ -0,0 +1,2 @@ +-- 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 new file mode 100644 index 0000000..56e4899 --- /dev/null +++ b/prisma/migrations/20241124141114_purchase_reflections_default_null/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "items" ALTER COLUMN "reflections" SET DEFAULT null; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcf290e..0e4a918 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,16 +20,17 @@ model User { } model Item { - id Int @id @default(autoincrement()) - name String - amount Float - datetime DateTime - location String - notes String? - user User @relation(fields: [userId], references: [id]) - userId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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 a841a39..68544ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,12 +4,14 @@ import { generate as generatePurchases } from './purchase/store'; const personaPromise = generatePersona(); +console.log(`Generating persona...`); + personaPromise.then(id => { - console.log(`Persona generated! Now generating purchases for id ${id}`); + console.log(`Generating purchases for id ${id}...`); const purchasesPromise = generatePurchases(id); purchasesPromise.then(() => { - console.log('Purchases generated!'); + console.log('Complete'); }); }); diff --git a/src/persona/store.ts b/src/persona/store.ts index 8fc9cef..6034a88 100644 --- a/src/persona/store.ts +++ b/src/persona/store.ts @@ -1,21 +1,26 @@ +import 'dotenv/config'; import { prisma } from '../utils/prismaClient'; import fs from 'fs'; import { Persona } from './types'; import { Tool } from './tool'; -import { makeRequest } from '../utils/anthropicClient'; +import { BaseTool, makeRequest } from '../utils/anthropicClient'; import { createFolderIfNotExists } from '../utils/createFolder'; - -const prompt = - 'Generate a detailed, realistic customer persona that follows the schema structure exactly. Create someone whose traits, habits, and behaviors form a coherent narrative about their purchasing decisions. Randomly select one option from each seed group: LIFE STAGE [young professional | mid-career parent | empty nester | recent graduate | career shifter | semi-retired | newly married | single parent | remote worker | retiree] FINANCIAL STYLE [debt-averse minimalist | luxury spender | budget optimizer | investment-focused | experience seeker | conscious consumer | tech enthusiast | security planner | impulse buyer | traditional saver] LOCATION [urban core | older suburb | new suburb | small town | rural area | coastal city | mountain town | college town | cultural district | tech hub] SPECIAL FACTOR [health-focused | hobby enthusiast | side hustler | community leader | creative professional | outdoor adventurer | tech worker | environmental advocate | cultural enthusiast | academic] ATTITUDE [optimist | pragmatist | skeptic] TECH COMFORT [early adopter | mainstream | traditional] SOCIAL STYLE [extrovert | ambivert | introvert] SEASON [winter | spring | summer | fall]. Ensure all numerical values and scores are justified by the persona context and lifestyle.'; +import { generatePrompt } from '../utils/generatePersonaSeed'; export async function generate() { - const result = (await makeRequest(prompt, Tool as any)) as Persona; + 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); await saveToJson(result, id); - console.log('Persona:', result.core.name); + console.log(`Persona name: ${result.core.name}`); return id; } @@ -29,19 +34,17 @@ export async function saveToDb(persona: Persona) { } }); - console.log(`Persona ${result.name} inserted in DB with id ${result.id}`); + console.log(`Persona '${result.name}' inserted in DB with id ${result.id}`); return result.id; } export async function saveToJson(persona: Persona, id: number) { - await createFolderIfNotExists('personas'); + await createFolderIfNotExists(`personas/${id}/`); - await fs.promises.writeFile( - `personas/${id}.json`, - JSON.stringify(persona), - 'utf8' - ); + const jsonName = `personas/${id}/${id}-persona.json`; - console.log(`Persona ${persona.core.name} saved as persona/${id}.json`); + await fs.promises.writeFile(jsonName, JSON.stringify(persona), 'utf8'); + + console.log(`Persona '${persona.core.name}' saved as ${jsonName}`); } diff --git a/src/persona/types.ts b/src/persona/types.ts index 03a0c11..f566980 100644 --- a/src/persona/types.ts +++ b/src/persona/types.ts @@ -1,5 +1,5 @@ interface Pet { - [key: string]: any; + [key: string]: unknown; } interface FrequencyObject { diff --git a/src/purchase/promptGenerator.ts b/src/purchase/promptGenerator.ts index c318185..408e317 100644 --- a/src/purchase/promptGenerator.ts +++ b/src/purchase/promptGenerator.ts @@ -40,7 +40,10 @@ function formatList(items?: string[]): string { return items.join(', '); } -async function generatePurchasePrompt(persona: Persona): Promise { +export async function generatePurchasePrompt( + persona: Persona, + reflectionThreshold: number +): Promise { try { const sections: string[] = []; @@ -187,13 +190,49 @@ ${persona.context?.recent_changes?.length ? `- Recent lifestyle changes: ${forma 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) { - console.error('Error generating prompt:', error); - throw new Error('Failed to generate purchase prompt'); + throw new Error(JSON.stringify(error)); } } - -export default generatePurchasePrompt; diff --git a/src/purchase/store.ts b/src/purchase/store.ts index b775258..c5f59bb 100644 --- a/src/purchase/store.ts +++ b/src/purchase/store.ts @@ -1,57 +1,74 @@ import { prisma } from '../utils/prismaClient'; import fs, { readFileSync } from 'fs'; -import { PurchaseList } from './types'; +import { PurchaseList, Reflection } from './types'; import { Tool } from './tool'; -import { makeRequest } from '../utils/anthropicClient'; +import { BaseTool, makeRequest } from '../utils/anthropicClient'; import { createFolderIfNotExists } from '../utils/createFolder'; -import generatePurchasePrompt from './promptGenerator'; +import { generatePurchasePrompt } from './promptGenerator'; +import { Persona } from '../persona/types'; +import { Prisma } from '@prisma/client'; export async function generate(personaId: number) { - const jsonFile = readFileSync(`personas/${personaId}.json`, 'utf-8'); + const jsonFile = readFileSync( + `personas/${personaId}/${personaId}-persona.json`, + 'utf-8' + ); - const persona = JSON.parse(jsonFile); + const persona: Persona = JSON.parse(jsonFile); - const personaPrompt = await generatePurchasePrompt(persona); + const personaPrompt = await generatePurchasePrompt( + persona, + parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50') + ); const result = (await makeRequest( personaPrompt, - Tool as any + Tool as BaseTool )) as PurchaseList; await saveToDb(personaId, result); await saveToJson(result, personaId); - console.log('Purchases:', result.items.length); + console.log(`Generated ${result.items.length} purchases`); +} + +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 - })) + 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 with persona ${personaId}`); + console.log(`Inserted ${result.count} purchases for persona ${personaId}`); } export async function saveToJson(purchaseList: PurchaseList, id: number) { - await createFolderIfNotExists('purchases'); + await createFolderIfNotExists(`personas/${id}/`); - await createFolderIfNotExists(`purchases/${id}`); + const jsonName = `personas/${id}/${id}-purchases.json`; - await fs.promises.writeFile( - `purchases/${id}/${id}.json`, - JSON.stringify(purchaseList), - 'utf8' - ); + await fs.promises.writeFile(jsonName, JSON.stringify(purchaseList), 'utf8'); - console.log( - `Saved ${purchaseList.items.length} purchases as purchases/${id}/${id}.json` - ); + console.log(`Saved ${purchaseList.items.length} purchases as ${jsonName}`); } diff --git a/src/purchase/tool.ts b/src/purchase/tool.ts index 7dcf531..6b35a15 100644 --- a/src/purchase/tool.ts +++ b/src/purchase/tool.ts @@ -14,7 +14,7 @@ export const Tool = { }, amount: { type: 'number' as const, - description: 'Purchase amount in USD' + description: 'Purchase amount in EUR' }, datetime: { type: 'string' as const, @@ -28,6 +28,36 @@ export const Tool = { notes: { type: 'string' as const, description: 'Additional purchase details (optional)' + }, + reflections: { + type: 'array' as const, + description: + 'Array of reflections on purchases over threshold amount', + 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: 'Date of the reflection in ISO 8601 format', + format: 'date-time' + }, + mood: { + type: 'string' as const, + description: 'Optional context about mood during reflection' + } + }, + required: ['comment', 'satisfactionScore', 'date'] as const + } } }, required: ['name', 'amount', 'datetime', 'location'] as const diff --git a/src/purchase/types.ts b/src/purchase/types.ts index 5a233ac..1196de2 100644 --- a/src/purchase/types.ts +++ b/src/purchase/types.ts @@ -1,11 +1,26 @@ +export interface Reflection { + comment: string; + satisfactionScore: number; + date: string; + mood?: string | null; +} + export interface Purchase { name: string; amount: number; datetime: string; location: string; notes?: string; + reflections?: Reflection[]; } export interface PurchaseList { items: Purchase[]; } + +export interface ReflectionJson { + comment: string; + satisfactionScore: number; + date: string; + mood: string | null; +} diff --git a/src/utils/anthropicClient.ts b/src/utils/anthropicClient.ts index c743a29..5e63bef 100644 --- a/src/utils/anthropicClient.ts +++ b/src/utils/anthropicClient.ts @@ -1,7 +1,17 @@ import 'dotenv/config'; import Anthropic from '@anthropic-ai/sdk'; -export async function makeRequest(prompt: string, tool: any) { +export interface BaseTool { + readonly name: string; + readonly input_schema: { + readonly type: 'object'; + readonly properties: Record; + readonly required?: readonly string[]; + readonly description?: string; + }; +} + +export async function makeRequest(prompt: string, tool: T) { if (!process.env.ANTHROPIC_API_KEY) { throw Error('Anthropic API key missing.'); } @@ -12,7 +22,7 @@ export async function makeRequest(prompt: string, tool: any) { const response = await anthropic.messages.create({ model: 'claude-3-5-sonnet-20241022', - max_tokens: 2000, + max_tokens: 8192, temperature: 1, tools: [tool], messages: [{ role: 'user', content: prompt }] diff --git a/src/utils/generatePersonaSeed.ts b/src/utils/generatePersonaSeed.ts new file mode 100644 index 0000000..464cb12 --- /dev/null +++ b/src/utils/generatePersonaSeed.ts @@ -0,0 +1,121 @@ +const PROVINCE_CODES = [ + '00', // Roma + '04', // Latina + '10', // Torino + '12', // Cuneo + '16', // Genova + '20', // Milano + '24', // Bergamo + '25', // Brescia + '30', // Venezia + '31', // Treviso + '35', // Padova + '40', // Bologna + '45', // Rovigo + '47', // Forli-Cesena + '48', // Ravenna + '50', // Firenze + '51', // Pistoia + '52', // Arezzo + '53', // Siena + '54', // Massa-Carrara + '55', // Lucca + '56', // Pisa + '57', // Livorno + '58', // Grosseto + '60', // Ancona + '61', // Pesaro + '63', // Ascoli Piceno + '65', // Pescara + '66', // Chieti + '67', // L'Aquila + '70', // Bari + '71', // Foggia + '72', // Brindisi + '73', // Lecce + '74', // Taranto + '75', // Matera + '80', // Napoli + '81', // Caserta + '82', // Benevento + '83', // Avellino + '84', // Salerno + '87', // Cosenza + '88', // Catanzaro + '89', // Reggio Calabria + '90', // Palermo + '91', // Trapani + '92', // Agrigento + '93', // Caltanissetta + '94', // Enna + '95', // Catania + '96', // Siracusa + '97', // Ragusa + '98' // Messina +]; + +export function generateRandomCAP(): string { + const provinceCode = + PROVINCE_CODES[Math.floor(Math.random() * PROVINCE_CODES.length)]; + + const lastThreeDigits = Math.floor(Math.random() * 1000) + .toString() + .padStart(3, '0'); + + return `${provinceCode}${lastThreeDigits}`; +} + +function generateLetters(): string { + const consonants = 'BCDFGLMNPRSTVZ'; + const vowels = 'AEIOU'; + + let result = ''; + + const consonantCount = 4 + Math.floor(Math.random() * 2); + for (let i = 0; i < consonantCount; i++) { + result += consonants[Math.floor(Math.random() * consonants.length)]; + } + + const extraVowels = 3 + Math.floor(Math.random() * 2); + for (let i = 0; i < extraVowels; i++) { + result += vowels[Math.floor(Math.random() * vowels.length)]; + } + + return result + .split('') + .sort(() => Math.random() - 0.5) + .join(''); +} + +function generateBirthYear(): string { + const currentYear = new Date().getFullYear(); + const minYear = currentYear - 50; + const maxYear = currentYear - 20; + return Math.floor(Math.random() * (maxYear - minYear) + minYear).toString(); +} + +function formatPersonaSeed( + letters: string, + year: string, + postalCode: string +): string { + return `${letters}:${year}:${postalCode}`; +} + +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}.`; +}