feat: add purchases generation

This commit is contained in:
2024-11-24 11:16:42 +01:00
parent d5fd74b0c5
commit a73f5b883c
16 changed files with 408 additions and 132 deletions

View File

@@ -4,7 +4,6 @@
"es2021": true
},
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
@@ -17,8 +16,7 @@
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-definitions": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
"@typescript-eslint/consistent-type-definitions": "error"
},
"ignorePatterns": ["node_modules/", "dist/"]
}

1
.gitignore vendored
View File

@@ -132,5 +132,6 @@ dist
.editorconfig
personas/
purchases/
.DS_Store

View File

@@ -4,7 +4,7 @@
"start": "prisma generate && node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc",
"lint": "next lint",
"lint": "eslint . --fix",
"format": "prettier --config .prettierrc '**/*.{ts,json,md}' --write",
"typecheck": "tsc --noEmit",
"prepare": "husky install",

View File

@@ -1,17 +1,15 @@
import { makeRequest } from './utils/anthropicClient';
import { savePersonaJson } from './utils/savePersonaJson';
import { savePersonaDb } from './utils/savePersonaDb';
import { generatePromptWithMBTI } from './utils/personalityTrait';
import { generate as generatePersona } from './persona/store';
const prompt =
'Generate a detailed, realistic persona with specific real-world values including store names, brands and locations. Use frequencies per week, ISO timestamps relative to the current week. Personality trait: <MBTI_AND_TRAITS_HERE>';
import { generate as generatePurchases } from './purchase/store';
const fullPrompt = generatePromptWithMBTI(prompt);
const personaPromise = generatePersona();
const personaPromise = makeRequest(fullPrompt);
personaPromise.then(id => {
console.log(`Persona generated! Now generating purchases for id ${id}`);
personaPromise.then(persona => {
savePersonaDb(persona).then(id => savePersonaJson(persona, id));
const purchasesPromise = generatePurchases(id);
console.log('New persona:', persona);
purchasesPromise.then(() => {
console.log('Purchases generated!');
});
});

47
src/persona/store.ts Normal file
View File

@@ -0,0 +1,47 @@
import { prisma } from '../utils/prismaClient';
import fs from 'fs';
import { Persona } from './types';
import { Tool } from './tool';
import { 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.';
export async function generate() {
const result = (await makeRequest(prompt, Tool as any)) as Persona;
const id = await saveToDb(result);
await saveToJson(result, id);
console.log('Persona:', result.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) {
await createFolderIfNotExists('personas');
await fs.promises.writeFile(
`personas/${id}.json`,
JSON.stringify(persona),
'utf8'
);
console.log(`Persona ${persona.core.name} saved as persona/${id}.json`);
}

View File

@@ -1,4 +1,4 @@
export const PersonaTool = {
export const Tool = {
name: 'PersonaSchema' as const,
input_schema: {
type: 'object' as const,

View File

@@ -1,55 +1,55 @@
type Pet = {
interface Pet {
[key: string]: any;
};
}
type FrequencyObject = {
interface FrequencyObject {
name: string;
frequency: number;
};
}
type SubscriptionBill = {
interface SubscriptionBill {
name: string;
amount: number;
date: string | Date;
};
}
type ActivityObject = {
interface ActivityObject {
name: string;
frequency: number;
schedule?: string[];
};
}
type BrandLoyalty = {
interface BrandLoyalty {
name: string;
loyaltyScore: number;
};
}
type Event = {
interface Event {
name: string;
date: string | Date;
details?: string;
};
}
type TimelineActivity = {
interface TimelineActivity {
activity: string;
duration: string;
location?: string;
};
}
type RegularStop = {
interface RegularStop {
location: string;
purpose: string;
frequency: string;
};
}
type SpendingCategories = {
interface SpendingCategories {
[category: string]: {
preference: number;
frequency: number;
};
};
}
export type Persona = {
export interface Persona {
core: {
age: number;
name: string;
@@ -115,9 +115,4 @@ export type Persona = {
upcoming_events: Event[];
recent_changes: string[];
};
};
export interface MBTIType {
type: string;
traits: string[];
}

View File

@@ -0,0 +1,199 @@
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(', ');
}
async function generatePurchasePrompt(persona: Persona): 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]
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');
}
}
export default generatePurchasePrompt;

57
src/purchase/store.ts Normal file
View File

@@ -0,0 +1,57 @@
import { prisma } from '../utils/prismaClient';
import fs, { readFileSync } from 'fs';
import { PurchaseList } from './types';
import { Tool } from './tool';
import { makeRequest } from '../utils/anthropicClient';
import { createFolderIfNotExists } from '../utils/createFolder';
import generatePurchasePrompt from './promptGenerator';
export async function generate(personaId: number) {
const jsonFile = readFileSync(`personas/${personaId}.json`, 'utf-8');
const persona = JSON.parse(jsonFile);
const personaPrompt = await generatePurchasePrompt(persona);
const result = (await makeRequest(
personaPrompt,
Tool as any
)) as PurchaseList;
await saveToDb(personaId, result);
await saveToJson(result, personaId);
console.log('Purchases:', result.items.length);
}
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
}))
});
console.log(`Inserted ${result.count} purchases with persona ${personaId}`);
}
export async function saveToJson(purchaseList: PurchaseList, id: number) {
await createFolderIfNotExists('purchases');
await createFolderIfNotExists(`purchases/${id}`);
await fs.promises.writeFile(
`purchases/${id}/${id}.json`,
JSON.stringify(purchaseList),
'utf8'
);
console.log(
`Saved ${purchaseList.items.length} purchases as purchases/${id}/${id}.json`
);
}

39
src/purchase/tool.ts Normal file
View File

@@ -0,0 +1,39 @@
export const Tool = {
name: 'PurchaseSchema' as const,
input_schema: {
type: 'object' as const,
properties: {
items: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
name: {
type: 'string' as const,
description: 'Name of the purchased item'
},
amount: {
type: 'number' as const,
description: 'Purchase amount in USD'
},
datetime: {
type: 'string' as const,
description: 'Purchase date and time in ISO 8601 format',
format: 'date-time'
},
location: {
type: 'string' as const,
description: 'Purchase location'
},
notes: {
type: 'string' as const,
description: 'Additional purchase details (optional)'
}
},
required: ['name', 'amount', 'datetime', 'location'] as const
}
}
},
required: ['items'] as const
}
} as const;

11
src/purchase/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Purchase {
name: string;
amount: number;
datetime: string;
location: string;
notes?: string;
}
export interface PurchaseList {
items: Purchase[];
}

View File

@@ -1,9 +1,7 @@
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
import { Persona } from './types';
import { PersonaTool } from './personaSchema';
export async function makeRequest(prompt: string) {
export async function makeRequest(prompt: string, tool: any) {
if (!process.env.ANTHROPIC_API_KEY) {
throw Error('Anthropic API key missing.');
}
@@ -16,7 +14,7 @@ export async function makeRequest(prompt: string) {
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2000,
temperature: 1,
tools: [PersonaTool],
tools: [tool],
messages: [{ role: 'user', content: prompt }]
});
@@ -33,5 +31,5 @@ export async function makeRequest(prompt: string) {
{ type: string; input: object }
];
return content[1].input as Persona;
return content[1].input;
}

18
src/utils/createFolder.ts Normal file
View File

@@ -0,0 +1,18 @@
import { promises as fs } from 'fs';
export async function createFolderIfNotExists(
folderPath: string
): Promise<void> {
try {
await fs.access(folderPath);
console.log('Folder already exists');
} catch {
try {
await fs.mkdir(folderPath, { recursive: true });
console.log('Folder created successfully');
} catch (error) {
console.error('Error creating folder:', error);
throw error;
}
}
}

View File

@@ -1,61 +0,0 @@
import { MBTIType } from './types';
const mbtiTypes: MBTIType[] = [
{
type: 'INTJ',
traits: ['analytical', 'planning-focused', 'independent', 'private']
},
{
type: 'ENTJ',
traits: ['strategic', 'leadership-oriented', 'decisive', 'organized']
},
{
type: 'ISFP',
traits: ['artistic', 'spontaneous', 'nature-loving', 'adaptable']
},
{ type: 'ESFJ', traits: ['caring', 'social', 'traditional', 'organized'] },
{ type: 'INTP', traits: ['logical', 'abstract', 'adaptable', 'private'] },
{
type: 'ENFP',
traits: ['enthusiastic', 'creative', 'spontaneous', 'people-oriented']
},
{ type: 'ISTJ', traits: ['practical', 'factual', 'organized', 'reliable'] },
{
type: 'ENFJ',
traits: ['charismatic', 'inspiring', 'idealistic', 'people-focused']
},
{
type: 'ISTP',
traits: ['practical', 'adaptable', 'experiential', 'logical']
},
{ type: 'ESFP', traits: ['spontaneous', 'energetic', 'social', 'practical'] },
{
type: 'INFJ',
traits: ['idealistic', 'organized', 'insightful', 'private']
},
{
type: 'ESTP',
traits: ['energetic', 'practical', 'spontaneous', 'experiential']
},
{ type: 'INFP', traits: ['idealistic', 'creative', 'authentic', 'adaptive'] },
{
type: 'ENTP',
traits: ['innovative', 'adaptable', 'analytical', 'outgoing']
},
{ type: 'ISFJ', traits: ['practical', 'caring', 'organized', 'traditional'] },
{
type: 'ESTJ',
traits: ['practical', 'organized', 'leadership-oriented', 'traditional']
}
];
export function generatePromptWithMBTI(prompt: string): string {
const selectedType = mbtiTypes[Math.floor(Math.random() * mbtiTypes.length)];
const mbtiJson = JSON.stringify({
type: selectedType.type,
traits: selectedType.traits
});
return prompt.replace('<MBTI_AND_TRAITS_HERE>', mbtiJson);
}

View File

@@ -1,16 +0,0 @@
import { prisma } from './prismaClient';
import { Persona } from './types';
export async function savePersonaDb(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 with ID ${result.id}`);
return result.id;
}

View File

@@ -1,8 +0,0 @@
import fs from 'fs';
import { Persona } from './types';
export function savePersonaJson(persona: Persona, id: number) {
fs.promises.writeFile(`personas/${id}.json`, JSON.stringify(persona), 'utf8');
console.log(`Persona ${persona.core.name} saved as persona/${id}.json`);
}