From 21721cd1e16bca80fca4febcd42a78f198ff0603 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 28 Apr 2026 13:48:01 -0700 Subject: [PATCH] Add negotiation context engine --- .../negotiation/negotiationContext.js | 151 ++++++++++++++++++ .../negotiation/negotiationContext.test.js | 99 ++++++++++++ src/utils/messageGenerator.js | 68 ++------ 3 files changed, 260 insertions(+), 58 deletions(-) create mode 100644 src/features/negotiation/negotiationContext.js create mode 100644 src/features/negotiation/negotiationContext.test.js diff --git a/src/features/negotiation/negotiationContext.js b/src/features/negotiation/negotiationContext.js new file mode 100644 index 0000000..1fa43c7 --- /dev/null +++ b/src/features/negotiation/negotiationContext.js @@ -0,0 +1,151 @@ +export const OFFER_TYPES = { + unknown: 'unknown', + lowball: 'lowball', + counter: 'counter', + close: 'close', + acceptable: 'acceptable', + accept: 'accept', +} + +function normalizeMode(mode) { + return mode === 'buyer' ? 'buyer' : 'seller' +} + +function normalizePrice(price) { + if (price === null || price === undefined || price === '') { + return null + } + + const value = typeof price === 'string' ? Number(price.replace(/[$,]/g, '')) : Number(price) + return Number.isFinite(value) && value >= 0 ? value : null +} + +export function formatPrice(price) { + const value = normalizePrice(price) + if (value === null) { + return '' + } + + return value % 1 === 0 ? `$${value}` : `$${value.toFixed(2)}` +} + +export function classifyOffer({ askingPrice, theirOffer, minimumPrice } = {}) { + const asking = normalizePrice(askingPrice) + const offer = normalizePrice(theirOffer) + const minimum = normalizePrice(minimumPrice) + + if (!asking || offer === null) { + return OFFER_TYPES.unknown + } + + if (offer >= asking) { + return OFFER_TYPES.accept + } + + if (minimum !== null && offer >= minimum) { + return OFFER_TYPES.acceptable + } + + const offerPercentage = (offer / asking) * 100 + + if (offerPercentage < 50) { + return OFFER_TYPES.lowball + } + + if (offerPercentage < 75) { + return OFFER_TYPES.counter + } + + return OFFER_TYPES.close +} + +export function calculateCounterOffer({ askingPrice, theirOffer, minimumPrice } = {}) { + const asking = normalizePrice(askingPrice) + const offer = normalizePrice(theirOffer) + const minimum = normalizePrice(minimumPrice) + + if (!asking || offer === null) { + return null + } + + const midpoint = Math.round((offer + asking) / 2) + + if (minimum !== null) { + return Math.max(midpoint, minimum) + } + + return Math.max(midpoint, Math.round(asking * 0.85)) +} + +function getPercentOfAsking(askingPrice, theirOffer) { + if (!askingPrice || theirOffer === null) { + return null + } + + return Math.round((theirOffer / askingPrice) * 100) +} + +function getMissingFields({ item, askingPrice, theirOffer }) { + return [ + !item ? 'item' : null, + !askingPrice ? 'askingPrice' : null, + theirOffer === null ? 'theirOffer' : null, + ].filter(Boolean) +} + +function getNextMove(offerType) { + if (offerType === OFFER_TYPES.accept || offerType === OFFER_TYPES.acceptable) { + return 'accept-or-tighten-logistics' + } + + if (offerType === OFFER_TYPES.close) { + return 'counter-lightly' + } + + if (offerType === OFFER_TYPES.counter || offerType === OFFER_TYPES.lowball) { + return 'test-seriousness-then-counter' + } + + return 'gather-context' +} + +export function buildNegotiationContext({ + mode = 'seller', + item, + askingPrice, + theirOffer, + minimumPrice, +} = {}) { + const normalizedItem = item?.trim() || 'item' + const normalizedAskingPrice = normalizePrice(askingPrice) + const normalizedTheirOffer = normalizePrice(theirOffer) + const normalizedMinimumPrice = normalizePrice(minimumPrice) + const offerType = classifyOffer({ + askingPrice: normalizedAskingPrice, + theirOffer: normalizedTheirOffer, + minimumPrice: normalizedMinimumPrice, + }) + + return { + mode: normalizeMode(mode), + item: normalizedItem, + offerType, + prices: { + askingPrice: normalizedAskingPrice, + theirOffer: normalizedTheirOffer, + minimumPrice: normalizedMinimumPrice, + counterOffer: calculateCounterOffer({ + askingPrice: normalizedAskingPrice, + theirOffer: normalizedTheirOffer, + minimumPrice: normalizedMinimumPrice, + }), + percentOfAsking: getPercentOfAsking(normalizedAskingPrice, normalizedTheirOffer), + }, + missingFields: getMissingFields({ + item, + askingPrice: normalizedAskingPrice, + theirOffer: normalizedTheirOffer, + }), + nextMove: getNextMove(offerType), + } +} diff --git a/src/features/negotiation/negotiationContext.test.js b/src/features/negotiation/negotiationContext.test.js new file mode 100644 index 0000000..2762684 --- /dev/null +++ b/src/features/negotiation/negotiationContext.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + buildNegotiationContext, + calculateCounterOffer, + classifyOffer, + formatPrice, +} from './negotiationContext' + +describe('classifyOffer', () => { + it('classifies an offer below 50% of asking as a lowball', () => { + expect(classifyOffer({ askingPrice: 200, theirOffer: 50 })).toBe('lowball') + }) + + it('classifies an offer between 50% and 75% as counter territory', () => { + expect(classifyOffer({ askingPrice: 200, theirOffer: 130 })).toBe('counter') + }) + + it('classifies an offer at 75% or higher of asking as close', () => { + expect(classifyOffer({ askingPrice: 200, theirOffer: 170 })).toBe('close') + }) + + it('treats an offer at or above the minimum as acceptable before countering', () => { + expect(classifyOffer({ askingPrice: 200, theirOffer: 150, minimumPrice: 140 })).toBe( + 'acceptable', + ) + }) + + it('treats an offer at or above asking as a full accept', () => { + expect(classifyOffer({ askingPrice: 200, theirOffer: 200 })).toBe('accept') + }) +}) + +describe('calculateCounterOffer', () => { + it('counters at the higher of midpoint or 85% of asking when no minimum is set', () => { + expect(calculateCounterOffer({ askingPrice: 100, theirOffer: 30 })).toBe(85) + }) + + it('never counters below the minimum when one is set', () => { + expect(calculateCounterOffer({ askingPrice: 200, theirOffer: 80, minimumPrice: 150 })).toBe( + 150, + ) + }) +}) + +describe('formatPrice', () => { + it('formats whole-dollar and decimal prices consistently', () => { + expect(formatPrice(85)).toBe('$85') + expect(formatPrice(85.5)).toBe('$85.50') + }) +}) + +describe('buildNegotiationContext', () => { + it('creates structured seller context for future prompt assembly', () => { + const context = buildNegotiationContext({ + mode: 'seller', + item: ' gaming chair ', + askingPrice: 200, + theirOffer: 130, + minimumPrice: 120, + }) + + expect(context).toMatchObject({ + mode: 'seller', + item: 'gaming chair', + offerType: 'acceptable', + prices: { + askingPrice: 200, + theirOffer: 130, + minimumPrice: 120, + counterOffer: 165, + percentOfAsking: 65, + }, + nextMove: 'accept-or-tighten-logistics', + }) + }) + + it('keeps incomplete buyer context usable without fake price assumptions', () => { + const context = buildNegotiationContext({ + mode: 'buyer', + item: 'used pickup', + askingPrice: 12000, + }) + + expect(context).toMatchObject({ + mode: 'buyer', + item: 'used pickup', + offerType: 'unknown', + prices: { + askingPrice: 12000, + theirOffer: null, + minimumPrice: null, + counterOffer: null, + percentOfAsking: null, + }, + missingFields: ['theirOffer'], + nextMove: 'gather-context', + }) + }) +}) diff --git a/src/utils/messageGenerator.js b/src/utils/messageGenerator.js index 503329e..c00ff76 100644 --- a/src/utils/messageGenerator.js +++ b/src/utils/messageGenerator.js @@ -1,62 +1,14 @@ -/** - * Message Generator for Haggly - * - * Generates negotiation responses based on the offer type and user preferences. - * Templates are based on real-world negotiation best practices. - */ - -/** - * Determine the type of offer based on the numbers - */ -function getOfferType(askingPrice, theirOffer, minimumPrice) { - const offerPercentage = (theirOffer / askingPrice) * 100 - - // If they offered at or above asking price - accept! - if (theirOffer >= askingPrice) { - return 'accept' - } - - // If minimum is set and offer meets it - if (minimumPrice && theirOffer >= minimumPrice) { - return 'acceptable' - } - - // If offer is less than 50% - lowball - if (offerPercentage < 50) { - return 'lowball' - } - - // If offer is between 50-75% - counter territory - if (offerPercentage < 75) { - return 'counter' - } - - // If offer is 75%+ - close, could accept or counter slightly - return 'close' -} - -/** - * Calculate a smart counter offer - */ -function getCounterOffer(askingPrice, theirOffer, minimumPrice) { - // If minimum is set, counter halfway between their offer and asking (but not below minimum) - if (minimumPrice) { - const midpoint = Math.round((theirOffer + askingPrice) / 2) - return Math.max(midpoint, minimumPrice) - } - - // Default: counter at 85-90% of asking or midpoint, whichever is higher - const midpoint = Math.round((theirOffer + askingPrice) / 2) - const eightyFive = Math.round(askingPrice * 0.85) - return Math.max(midpoint, eightyFive) -} +import { + calculateCounterOffer, + classifyOffer, + formatPrice, +} from '../features/negotiation/negotiationContext' /** - * Format currency nicely + * Message Generator for Haggly + * + * Generates deterministic fallback responses based on structured negotiation logic. */ -function formatPrice(price) { - return price % 1 === 0 ? `$${price}` : `$${price.toFixed(2)}` -} /** * Message templates organized by offer type and tone @@ -157,8 +109,8 @@ const templates = { * Generate messages for all three tones */ export function generateMessages({ item, askingPrice, theirOffer, minimumPrice }) { - const offerType = getOfferType(askingPrice, theirOffer, minimumPrice) - const counterOffer = getCounterOffer(askingPrice, theirOffer, minimumPrice) + const offerType = classifyOffer({ askingPrice, theirOffer, minimumPrice }) + const counterOffer = calculateCounterOffer({ askingPrice, theirOffer, minimumPrice }) const replacements = { '{item}': item,