feat: add purchase reflections

This commit is contained in:
2024-11-24 21:31:59 +01:00
parent a73f5b883c
commit 8e7bfd2048
15 changed files with 372 additions and 63 deletions

View File

@@ -1,2 +1,4 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5433/persona?schema=public&connect_timeout=300" DATABASE_URL="postgresql://postgres:postgres@localhost:5433/persona?schema=public&connect_timeout=300"
PERSONA_PROMPT=
PURCHASE_REFLECTION_THRESHOLD=50
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=

View File

@@ -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.

View File

@@ -1,5 +1,7 @@
{ {
"name": "purchases-personas", "name": "purchases-personas",
"version": "1.0.0",
"description": "Generate realistic fictional personas and their weekly purchase behaviors using AI",
"scripts": { "scripts": {
"start": "prisma generate && node dist/index.js", "start": "prisma generate && node dist/index.js",
"dev": "nodemon src/index.ts", "dev": "nodemon src/index.ts",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "items" ADD COLUMN "reflections" JSONB;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "items" ALTER COLUMN "reflections" SET DEFAULT null;

View File

@@ -20,16 +20,17 @@ model User {
} }
model Item { model Item {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
amount Float amount Float
datetime DateTime datetime DateTime
location String location String
notes String? notes String?
user User @relation(fields: [userId], references: [id]) reflections Json? @default(dbgenerated("null"))
userId Int user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) userId Int
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("items") @@map("items")
} }

View File

@@ -4,12 +4,14 @@ import { generate as generatePurchases } from './purchase/store';
const personaPromise = generatePersona(); const personaPromise = generatePersona();
console.log(`Generating persona...`);
personaPromise.then(id => { personaPromise.then(id => {
console.log(`Persona generated! Now generating purchases for id ${id}`); console.log(`Generating purchases for id ${id}...`);
const purchasesPromise = generatePurchases(id); const purchasesPromise = generatePurchases(id);
purchasesPromise.then(() => { purchasesPromise.then(() => {
console.log('Purchases generated!'); console.log('Complete');
}); });
}); });

View File

@@ -1,21 +1,26 @@
import 'dotenv/config';
import { prisma } from '../utils/prismaClient'; import { prisma } from '../utils/prismaClient';
import fs from 'fs'; import fs from 'fs';
import { Persona } from './types'; import { Persona } from './types';
import { Tool } from './tool'; import { Tool } from './tool';
import { makeRequest } from '../utils/anthropicClient'; import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder'; import { createFolderIfNotExists } from '../utils/createFolder';
import { generatePrompt } from '../utils/generatePersonaSeed';
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.';
export async function generate() { 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); const id = await saveToDb(result);
await saveToJson(result, id); await saveToJson(result, id);
console.log('Persona:', result.core.name); console.log(`Persona name: ${result.core.name}`);
return id; 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; return result.id;
} }
export async function saveToJson(persona: Persona, id: number) { export async function saveToJson(persona: Persona, id: number) {
await createFolderIfNotExists('personas'); await createFolderIfNotExists(`personas/${id}/`);
await fs.promises.writeFile( const jsonName = `personas/${id}/${id}-persona.json`;
`personas/${id}.json`,
JSON.stringify(persona),
'utf8'
);
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}`);
} }

View File

@@ -1,5 +1,5 @@
interface Pet { interface Pet {
[key: string]: any; [key: string]: unknown;
} }
interface FrequencyObject { interface FrequencyObject {

View File

@@ -40,7 +40,10 @@ function formatList(items?: string[]): string {
return items.join(', '); return items.join(', ');
} }
async function generatePurchasePrompt(persona: Persona): Promise<string> { export async function generatePurchasePrompt(
persona: Persona,
reflectionThreshold: number
): Promise<string> {
try { try {
const sections: string[] = []; const sections: string[] = [];
@@ -187,13 +190,49 @@ ${persona.context?.recent_changes?.length ? `- Recent lifestyle changes: ${forma
Format each purchase as: Format each purchase as:
[DATE] [TIME] | [STORE] | [ITEMS] | $[AMOUNT] | [CATEGORY] | [PLANNED/IMPULSE] [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.`); 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'); return sections.filter(section => section.trim().length > 0).join('\n\n');
} catch (error) { } catch (error) {
console.error('Error generating prompt:', error); throw new Error(JSON.stringify(error));
throw new Error('Failed to generate purchase prompt');
} }
} }
export default generatePurchasePrompt;

View File

@@ -1,57 +1,74 @@
import { prisma } from '../utils/prismaClient'; import { prisma } from '../utils/prismaClient';
import fs, { readFileSync } from 'fs'; import fs, { readFileSync } from 'fs';
import { PurchaseList } from './types'; import { PurchaseList, Reflection } from './types';
import { Tool } from './tool'; import { Tool } from './tool';
import { makeRequest } from '../utils/anthropicClient'; import { BaseTool, makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder'; 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) { 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( const result = (await makeRequest(
personaPrompt, personaPrompt,
Tool as any Tool as BaseTool
)) as PurchaseList; )) as PurchaseList;
await saveToDb(personaId, result); await saveToDb(personaId, result);
await saveToJson(result, personaId); 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) { export async function saveToDb(personaId: number, purchases: PurchaseList) {
const result = await prisma.item.createMany({ const result = await prisma.item.createMany({
data: purchases.items.map(purchase => ({ data: purchases.items.map(
userId: personaId, purchase =>
name: purchase.name, ({
amount: purchase.amount, userId: personaId,
datetime: purchase.datetime, name: purchase.name,
location: purchase.location, amount: purchase.amount,
notes: purchase.notes 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) { 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( await fs.promises.writeFile(jsonName, JSON.stringify(purchaseList), 'utf8');
`purchases/${id}/${id}.json`,
JSON.stringify(purchaseList),
'utf8'
);
console.log( console.log(`Saved ${purchaseList.items.length} purchases as ${jsonName}`);
`Saved ${purchaseList.items.length} purchases as purchases/${id}/${id}.json`
);
} }

View File

@@ -14,7 +14,7 @@ export const Tool = {
}, },
amount: { amount: {
type: 'number' as const, type: 'number' as const,
description: 'Purchase amount in USD' description: 'Purchase amount in EUR'
}, },
datetime: { datetime: {
type: 'string' as const, type: 'string' as const,
@@ -28,6 +28,36 @@ export const Tool = {
notes: { notes: {
type: 'string' as const, type: 'string' as const,
description: 'Additional purchase details (optional)' 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 required: ['name', 'amount', 'datetime', 'location'] as const

View File

@@ -1,11 +1,26 @@
export interface Reflection {
comment: string;
satisfactionScore: number;
date: string;
mood?: string | null;
}
export interface Purchase { export interface Purchase {
name: string; name: string;
amount: number; amount: number;
datetime: string; datetime: string;
location: string; location: string;
notes?: string; notes?: string;
reflections?: Reflection[];
} }
export interface PurchaseList { export interface PurchaseList {
items: Purchase[]; items: Purchase[];
} }
export interface ReflectionJson {
comment: string;
satisfactionScore: number;
date: string;
mood: string | null;
}

View File

@@ -1,7 +1,17 @@
import 'dotenv/config'; import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk'; 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<string, unknown>;
readonly required?: readonly string[];
readonly description?: string;
};
}
export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
if (!process.env.ANTHROPIC_API_KEY) { if (!process.env.ANTHROPIC_API_KEY) {
throw Error('Anthropic API key missing.'); throw Error('Anthropic API key missing.');
} }
@@ -12,7 +22,7 @@ export async function makeRequest(prompt: string, tool: any) {
const response = await anthropic.messages.create({ const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022', model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000, max_tokens: 8192,
temperature: 1, temperature: 1,
tools: [tool], tools: [tool],
messages: [{ role: 'user', content: prompt }] messages: [{ role: 'user', content: prompt }]

View File

@@ -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}.`;
}