From a73f5b883c50beea6eec976f4e887e02c311255d Mon Sep 17 00:00:00 2001 From: Riccardo Senica Date: Sun, 24 Nov 2024 11:16:42 +0100 Subject: [PATCH] feat: add purchases generation --- .eslintrc.json | 8 +- .gitignore | 1 + package.json | 2 +- src/index.ts | 20 +- src/persona/store.ts | 47 +++++ .../personaSchema.ts => persona/tool.ts} | 2 +- src/{utils => persona}/types.ts | 43 ++-- src/purchase/promptGenerator.ts | 199 ++++++++++++++++++ src/purchase/store.ts | 57 +++++ src/purchase/tool.ts | 39 ++++ src/purchase/types.ts | 11 + src/utils/anthropicClient.ts | 8 +- src/utils/createFolder.ts | 18 ++ src/utils/personalityTrait.ts | 61 ------ src/utils/savePersonaDb.ts | 16 -- src/utils/savePersonaJson.ts | 8 - 16 files changed, 408 insertions(+), 132 deletions(-) create mode 100644 src/persona/store.ts rename src/{utils/personaSchema.ts => persona/tool.ts} (99%) rename src/{utils => persona}/types.ts (85%) create mode 100644 src/purchase/promptGenerator.ts create mode 100644 src/purchase/store.ts create mode 100644 src/purchase/tool.ts create mode 100644 src/purchase/types.ts create mode 100644 src/utils/createFolder.ts delete mode 100644 src/utils/personalityTrait.ts delete mode 100644 src/utils/savePersonaDb.ts delete mode 100644 src/utils/savePersonaJson.ts diff --git a/.eslintrc.json b/.eslintrc.json index f4c762f..9e2006c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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/"] } diff --git a/.gitignore b/.gitignore index 3163573..8e5b17d 100644 --- a/.gitignore +++ b/.gitignore @@ -132,5 +132,6 @@ dist .editorconfig personas/ +purchases/ .DS_Store \ No newline at end of file diff --git a/package.json b/package.json index 9f5b72d..ca68d57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index f50ff58..a841a39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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: '; +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!'); + }); }); diff --git a/src/persona/store.ts b/src/persona/store.ts new file mode 100644 index 0000000..8fc9cef --- /dev/null +++ b/src/persona/store.ts @@ -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`); +} diff --git a/src/utils/personaSchema.ts b/src/persona/tool.ts similarity index 99% rename from src/utils/personaSchema.ts rename to src/persona/tool.ts index 1a1c32e..216dfef 100644 --- a/src/utils/personaSchema.ts +++ b/src/persona/tool.ts @@ -1,4 +1,4 @@ -export const PersonaTool = { +export const Tool = { name: 'PersonaSchema' as const, input_schema: { type: 'object' as const, diff --git a/src/utils/types.ts b/src/persona/types.ts similarity index 85% rename from src/utils/types.ts rename to src/persona/types.ts index 1de2897..03a0c11 100644 --- a/src/utils/types.ts +++ b/src/persona/types.ts @@ -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[]; } diff --git a/src/purchase/promptGenerator.ts b/src/purchase/promptGenerator.ts new file mode 100644 index 0000000..c318185 --- /dev/null +++ b/src/purchase/promptGenerator.ts @@ -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 { + 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; diff --git a/src/purchase/store.ts b/src/purchase/store.ts new file mode 100644 index 0000000..b775258 --- /dev/null +++ b/src/purchase/store.ts @@ -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` + ); +} diff --git a/src/purchase/tool.ts b/src/purchase/tool.ts new file mode 100644 index 0000000..7dcf531 --- /dev/null +++ b/src/purchase/tool.ts @@ -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; diff --git a/src/purchase/types.ts b/src/purchase/types.ts new file mode 100644 index 0000000..5a233ac --- /dev/null +++ b/src/purchase/types.ts @@ -0,0 +1,11 @@ +export interface Purchase { + name: string; + amount: number; + datetime: string; + location: string; + notes?: string; +} + +export interface PurchaseList { + items: Purchase[]; +} diff --git a/src/utils/anthropicClient.ts b/src/utils/anthropicClient.ts index 7d2a9bf..c743a29 100644 --- a/src/utils/anthropicClient.ts +++ b/src/utils/anthropicClient.ts @@ -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; } diff --git a/src/utils/createFolder.ts b/src/utils/createFolder.ts new file mode 100644 index 0000000..20ba1fb --- /dev/null +++ b/src/utils/createFolder.ts @@ -0,0 +1,18 @@ +import { promises as fs } from 'fs'; + +export async function createFolderIfNotExists( + folderPath: string +): Promise { + 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; + } + } +} diff --git a/src/utils/personalityTrait.ts b/src/utils/personalityTrait.ts deleted file mode 100644 index a661b30..0000000 --- a/src/utils/personalityTrait.ts +++ /dev/null @@ -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('', mbtiJson); -} diff --git a/src/utils/savePersonaDb.ts b/src/utils/savePersonaDb.ts deleted file mode 100644 index 7d09653..0000000 --- a/src/utils/savePersonaDb.ts +++ /dev/null @@ -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; -} diff --git a/src/utils/savePersonaJson.ts b/src/utils/savePersonaJson.ts deleted file mode 100644 index 87c3af3..0000000 --- a/src/utils/savePersonaJson.ts +++ /dev/null @@ -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`); -}