feat: add purchase reflections
This commit is contained in:
@@ -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=
|
||||
|
||||
65
README.md
65
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "reflections" JSONB;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ALTER COLUMN "reflections" SET DEFAULT null;
|
||||
@@ -26,6 +26,7 @@ model Item {
|
||||
datetime DateTime
|
||||
location String
|
||||
notes String?
|
||||
reflections Json? @default(dbgenerated("null"))
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
interface Pet {
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FrequencyObject {
|
||||
|
||||
@@ -40,7 +40,10 @@ function formatList(items?: string[]): string {
|
||||
return items.join(', ');
|
||||
}
|
||||
|
||||
async function generatePurchasePrompt(persona: Persona): Promise<string> {
|
||||
export async function generatePurchasePrompt(
|
||||
persona: Persona,
|
||||
reflectionThreshold: number
|
||||
): Promise<string> {
|
||||
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;
|
||||
|
||||
@@ -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 => ({
|
||||
data: purchases.items.map(
|
||||
purchase =>
|
||||
({
|
||||
userId: personaId,
|
||||
name: purchase.name,
|
||||
amount: purchase.amount,
|
||||
datetime: purchase.datetime,
|
||||
location: purchase.location,
|
||||
notes: purchase.notes
|
||||
}))
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
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 }]
|
||||
|
||||
121
src/utils/generatePersonaSeed.ts
Normal file
121
src/utils/generatePersonaSeed.ts
Normal 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}.`;
|
||||
}
|
||||
Reference in New Issue
Block a user