Send interesting markets based on groups, follows, similar bettors

This commit is contained in:
Ian Philips 2022-10-05 10:41:57 -06:00
parent b9ba3e75fa
commit 328aa1457d

View File

@ -2,12 +2,20 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getGroup, getPrivateUser, getUser, getValues, log } from './utils' import {
getAllPrivateUsers,
getGroup,
getPrivateUser,
getUser,
getValues,
isProd,
log,
} from './utils'
import { createRNG, shuffle } from '../../common/util/random' import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS, HOUR_MS } from '../../common/util/time' import { DAY_MS, HOUR_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Follow } from '../../common/follow' import { Follow } from '../../common/follow'
import { countBy, uniqBy } from 'lodash' import { countBy, uniq, uniqBy } from 'lodash'
import { sendInterestingMarketsEmail } from './emails' import { sendInterestingMarketsEmail } from './emails'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
@ -37,27 +45,28 @@ export async function getTrendingContracts() {
export async function sendTrendingMarketsEmailsToAllUsers() { export async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
// const privateUsers = const privateUsers = isProd()
// isProd() ? await getAllPrivateUsers()
// ? await getAllPrivateUsers() : filterDefined([
// filterDefined([ await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian ])
// ]) const privateUsersToSendEmailsTo = isProd()
const privateUsersToSendEmailsTo = ? privateUsers
// get all users that haven't unsubscribed from weekly emails .filter((user) => {
// isProd() // get all users that haven't unsubscribed from weekly emails
// ? privateUsers user.notificationPreferences.trending_markets.includes('email') &&
// .filter((user) => { !user.weeklyTrendingEmailSent
// user.notificationPreferences.trending_markets.includes('email') && })
// !user.weeklyTrendingEmailSent .slice(100) // Send the emails out in batches
// }) : privateUsers
// .slice(125) // Send the emails out in batches
// : // For testing different users on prod: (only send ian an email though)
// privateUsers // filterDefined([
filterDefined([ // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian
await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian // isProd()
await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // ? await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1') // prod Mik
]) // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian
// ])
log( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
@ -75,11 +84,13 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 20) .slice(0, 50)
// log(
// `Found ${trendingContracts.length} trending contracts:\n`, const uniqueTrendingContracts = removeSimilarQuestions(
// trendingContracts.map((c) => c.question).join('\n ') trendingContracts,
// ) trendingContracts,
true
).slice(0, 20)
await Promise.all( await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => { privateUsersToSendEmailsTo.map(async (privateUser) => {
@ -87,25 +98,45 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
return return
} }
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets(
privateUser.id
)
const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets(
privateUser.id,
unbetOnFollowedMarkets
)
const similarBettorsMarkets = await getSimilarBettorsMarkets(
privateUser.id,
unBetOnGroupMarkets
)
const marketsAvailableToSend = uniqBy( const marketsAvailableToSend = uniqBy(
[ [
...(await getUserUnBetOnFollowsMarkets( ...chooseRandomSubset(unbetOnFollowedMarkets, 2),
privateUser.id, // // Most people will belong to groups but may not follow other users,
privateUser.id // so choose more from the other subsets if the followed markets is sparse
)), ...chooseRandomSubset(
...(await getUserUnBetOnGroupsMarkets(privateUser.id)), unBetOnGroupMarkets,
...(await getSimilarBettorsMarkets(privateUser.id)), unbetOnFollowedMarkets.length === 0 ? 3 : 2
),
...chooseRandomSubset(
similarBettorsMarkets,
unbetOnFollowedMarkets.length === 0 ? 3 : 2
),
], ],
(contract) => contract.id (contract) => contract.id
) )
// at least send them trending contracts if nothing else // // at least send them trending contracts if nothing else
if (marketsAvailableToSend.length < numContractsToSend) if (marketsAvailableToSend.length < numContractsToSend)
marketsAvailableToSend.push( marketsAvailableToSend.push(
...trendingContracts ...removeSimilarQuestions(
uniqueTrendingContracts,
marketsAvailableToSend,
false
)
.filter( .filter(
(contract) => (contract) => !contract.uniqueBettorIds?.includes(privateUser.id)
!contract.uniqueBettorIds?.includes(privateUser.id) &&
!marketsAvailableToSend.map((c) => c.id).includes(contract.id)
) )
.slice(0, numContractsToSend - marketsAvailableToSend.length) .slice(0, numContractsToSend - marketsAvailableToSend.length)
) )
@ -129,17 +160,13 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return if (!user) return
console.log( log(
'sending contracts:', 'sending contracts:',
contractsToSend.map((c) => [c.question, c.popularityScore]) contractsToSend.map((c) => c.question + ' ' + c.popularityScore)
) )
// if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them // if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them
// await sendInterestingMarketsEmail(user, privateUser, contractsToSend) // await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await sendInterestingMarketsEmail( await sendInterestingMarketsEmail(user, privateUser, contractsToSend)
user,
privateUsersToSendEmailsTo[0],
contractsToSend
)
await firestore.collection('private-users').doc(user.id).update({ await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true, weeklyTrendingEmailSent: true,
}) })
@ -147,20 +174,12 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
) )
} }
// TODO: figure out a good minimum popularity score to filter by
const MINIMUM_POPULARITY_SCORE = 2 const MINIMUM_POPULARITY_SCORE = 2
const getUserUnBetOnFollowsMarkets = async ( const getUserUnBetOnFollowsMarkets = async (userId: string) => {
userId: string,
unBetOnByUserId: string
) => {
const follows = await getValues<Follow>( const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows') firestore.collection('users').doc(userId).collection('follows')
) )
console.log(
'follows',
follows.map((f) => f.userId)
)
const unBetOnContractsFromFollows = await Promise.all( const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => { follows.map(async (follow) => {
@ -181,7 +200,7 @@ const getUserUnBetOnFollowsMarkets = async (
) )
return openContracts.filter( return openContracts.filter(
(contract) => !contract.uniqueBettorIds?.includes(unBetOnByUserId) (contract) => !contract.uniqueBettorIds?.includes(userId)
) )
}) })
) )
@ -194,16 +213,25 @@ const getUserUnBetOnFollowsMarkets = async (
contract.popularityScore > MINIMUM_POPULARITY_SCORE contract.popularityScore > MINIMUM_POPULARITY_SCORE
) )
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'sorted top 10 follow Markets', const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets sortedMarkets,
.slice(0, 10) sortedMarkets,
.map((c) => [c.question, c.popularityScore, c.creatorId]) true
) )
return sortedMarkets
const topSortedMarkets = uniqueSortedMarkets.slice(0, 10)
log(
'top 10 sorted markets by followed users',
topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
)
return topSortedMarkets
} }
const getUserUnBetOnGroupsMarkets = async (userId: string) => { const getUserUnBetOnGroupsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
const snap = await firestore const snap = await firestore
.collectionGroup('groupMembers') .collectionGroup('groupMembers')
.where('userId', '==', userId) .where('userId', '==', userId)
@ -215,10 +243,6 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
const groups = filterDefined( const groups = filterDefined(
await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId))) await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId)))
) )
console.log(
'groups',
groups.map((g) => g.name)
)
const unBetOnContractsFromGroups = await Promise.all( const unBetOnContractsFromGroups = await Promise.all(
groups.map(async (group) => { groups.map(async (group) => {
const unresolvedContracts = await getValues<Contract>( const unresolvedContracts = await getValues<Contract>(
@ -242,6 +266,7 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
) )
}) })
) )
const sortedMarkets = unBetOnContractsFromGroups const sortedMarkets = unBetOnContractsFromGroups
.flat() .flat()
.filter( .filter(
@ -250,17 +275,30 @@ const getUserUnBetOnGroupsMarkets = async (userId: string) => {
contract.popularityScore > MINIMUM_POPULARITY_SCORE contract.popularityScore > MINIMUM_POPULARITY_SCORE
) )
.sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0))
console.log(
'top 10 sorted group Markets', const uniqueSortedMarkets = removeSimilarQuestions(
sortedMarkets sortedMarkets,
.slice(0, 10) sortedMarkets,
.map((c) => [c.question, c.popularityScore, c.groupSlugs]) true
) )
return sortedMarkets const topSortedMarkets = removeSimilarQuestions(
uniqueSortedMarkets,
differentThanTheseContracts,
false
).slice(0, 10)
log(
'top 10 sorted group markets',
topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore)
)
return topSortedMarkets
} }
// Gets markets followed by similar bettors and bet on by similar bettors // Gets markets followed by similar bettors and bet on by similar bettors
const getSimilarBettorsMarkets = async (userId: string) => { const getSimilarBettorsMarkets = async (
userId: string,
differentThanTheseContracts: Contract[]
) => {
// get contracts with unique bettor ids with this user // get contracts with unique bettor ids with this user
const contractsUserHasBetOn = await getValues<Contract>( const contractsUserHasBetOn = await getValues<Contract>(
firestore firestore
@ -272,7 +310,6 @@ const getSimilarBettorsMarkets = async (userId: string) => {
contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(), contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(),
(bettorId) => bettorId (bettorId) => bettorId
) )
console.log('bettorIdCounts', bettorIdsToCounts)
// sort by number of times they appear with at least 2 appearances // sort by number of times they appear with at least 2 appearances
const sortedBettorIds = Object.entries(bettorIdsToCounts) const sortedBettorIds = Object.entries(bettorIdsToCounts)
@ -283,7 +320,6 @@ const getSimilarBettorsMarkets = async (userId: string) => {
// get the top 10 most similar bettors (excluding this user) // get the top 10 most similar bettors (excluding this user)
const similarBettorIds = sortedBettorIds.slice(0, 10) const similarBettorIds = sortedBettorIds.slice(0, 10)
console.log('top sortedBettorIds', similarBettorIds)
// get contracts with unique bettor ids with this user // get contracts with unique bettor ids with this user
const contractsSimilarBettorsHaveBetOn = ( const contractsSimilarBettorsHaveBetOn = (
@ -296,44 +332,89 @@ const getSimilarBettorsMarkets = async (userId: string) => {
similarBettorIds.slice(0, 10) similarBettorIds.slice(0, 10)
) )
.orderBy('popularityScore', 'desc') .orderBy('popularityScore', 'desc')
.limit(100) .limit(200)
) )
).filter((contract) => !contract.uniqueBettorIds?.includes(userId)) ).filter((contract) => !contract.uniqueBettorIds?.includes(userId))
// sort the contracts by how many times similar bettor ids are in their unique bettor ids array // sort the contracts by how many times similar bettor ids are in their unique bettor ids array
const sortedContractsToAppearancesInSimilarBettorsBets = const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn
contractsSimilarBettorsHaveBetOn .map((contract) => {
.map((contract) => { const appearances = contract.uniqueBettorIds?.filter((bettorId) =>
const appearances = contract.uniqueBettorIds?.filter((bettorId) => similarBettorIds.includes(bettorId)
similarBettorIds.includes(bettorId) ).length
).length return [contract, appearances] as [Contract, number]
return [contract, appearances] as [Contract, number] })
}) .sort((a, b) => b[1] - a[1])
.sort((a, b) => b[1] - a[1]) .map((entry) => entry[0])
console.log(
'sortedContractsToAppearancesInSimilarBettorsBets', const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions(
sortedContractsToAppearancesInSimilarBettorsBets.map((c) => [ sortedContractsInSimilarBettorsBets,
c[0].question, sortedContractsInSimilarBettorsBets,
c[1], true
])
) )
const topMostSimilarContracts = const topMostSimilarContracts = removeSimilarQuestions(
sortedContractsToAppearancesInSimilarBettorsBets.map((entry) => entry[0]) uniqueSortedContractsInSimilarBettorsBets,
differentThanTheseContracts,
false
).slice(0, 10)
console.log( log(
'top 10 sortedContractsToAppearancesInSimilarBettorsBets', 'top 10 sorted contracts other similar bettors have bet on',
topMostSimilarContracts topMostSimilarContracts.map((c) => c.question)
.map((c) => [
c.question,
c.uniqueBettorIds?.filter((bid) => similarBettorIds.includes(bid)),
])
.slice(0, 10)
) )
return topMostSimilarContracts return topMostSimilarContracts
} }
// search contract array by question and remove contracts with 3 matching words in the question
const removeSimilarQuestions = (
contractsToFilter: Contract[],
byContracts: Contract[],
allowExactSameContracts: boolean
) => {
// log(
// 'contracts to filter by',
// byContracts.map((c) => c.question + ' ' + c.popularityScore)
// )
let contractsToRemove: Contract[] = []
byContracts.length > 0 &&
byContracts.forEach((contract) => {
const contractQuestion = stripNonAlphaChars(contract.question)
// Don't lowercase so we match the proper nouns, which are the ones we're really looking for
const contractQuestionWords = uniq(contractQuestion.split(' ')).filter(
(w) => !IGNORE_WORDS.includes(w.toLowerCase())
)
contractsToRemove = contractsToRemove.concat(
contractsToFilter.filter(
// Remove contracts with more than 3 matching words and a lower popularity score
(c2) => {
const significantOverlap =
uniq(stripNonAlphaChars(c2.question).split(' ')).filter((word) =>
contractQuestionWords.includes(word)
).length > 3
const lessPopular =
(c2.popularityScore ?? 0) < (contract.popularityScore ?? 0)
return (
(significantOverlap && lessPopular) ||
(allowExactSameContracts ? false : c2.id === contract.id)
)
}
)
)
})
// log(
// 'contracts to filter out',
// contractsToRemove.map((c) => c.question)
// )
const returnContracts = contractsToFilter.filter(
(cf) => !contractsToRemove.map((c) => c.id).includes(cf.id)
)
return returnContracts
}
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()
const rng = createRNG(seed) const rng = createRNG(seed)
@ -342,3 +423,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng) shuffle(contracts, rng)
return contracts.slice(0, count) return contracts.slice(0, count)
} }
function stripNonAlphaChars(str: string) {
return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ')
}
const IGNORE_WORDS = [
'the',
'a',
'an',
'and',
'or',
'of',
'to',
'in',
'on',
'will',
'be',
'is',
'are',
'for',
'by',
'at',
'from',
'what',
'when',
'which',
'that',
'it',
'as',
'if',
'then',
'than',
'but',
'have',
'has',
'had',
]