Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

profile.js 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. const Schmervice = require('@hapipal/schmervice')
  2. const cosineSimilarity = require('compute-cosine-similarity')
  3. const haversine = require('haversine')
  4. const runMatch = (allYins, allYangs) => {
  5. balanceSides(allYins, allYangs)
  6. console.log(allYins.length, ':', allYangs.length)
  7. // You only need to engage from one side
  8. engageEveryone(allYins)
  9. console.log('---')
  10. }
  11. const engageEveryone = allYins => {
  12. let done
  13. do {
  14. console.log('rerunning...')
  15. done = true
  16. allYins.forEach(yin => {
  17. // Keep matching if no true pairing is found
  18. console.log(yin.realId, yin.otp?.realId)
  19. if (!yin.otp) {
  20. done = false
  21. const yang = yin.getNextCandidate()
  22. if (!yang.otp || yang.prefers(yin)) {
  23. yin.engageTo(yang)
  24. }
  25. } else {
  26. console.log(yin.otp.realId)
  27. }
  28. })
  29. } while (!done)
  30. }
  31. const balanceSides = (yins, yangs) => {
  32. let diff = Math.abs(yangs.length - yins.length)
  33. let smallerList = yangs.length < yins.length ? yangs : yins
  34. const totalProfiles = yangs.length + yins.length + 1
  35. for (let i = 0; i < diff; i++) {
  36. smallerList.push(new ProfileFacade(totalProfiles + i))
  37. }
  38. }
  39. class ProfileFacade {
  40. constructor(id, matchQueue) {
  41. this.realId = id ? id : undefined
  42. this.matchCandidateIndex = 0
  43. this.otp = null
  44. this.matchQueue = matchQueue?.length ? matchQueue : []
  45. this.fOrder = []
  46. }
  47. clearPlaceholderMatches() {
  48. this.matchQueue = this.matchQueue.filter(match => match.realId == false)
  49. this.fOrder = this.fOrder.filter(
  50. potentialFiance => potentialFiance.realId == false,
  51. )
  52. if (this.otp && this.otp.realId) {
  53. this.otp = null
  54. }
  55. }
  56. rank(id) {
  57. const idQueue = this.matchQueue.map(
  58. profileFacade => profileFacade.realId,
  59. )
  60. return idQueue.includes(id)
  61. ? idQueue.indexOf(id)
  62. : this.matchQueue.length + 1
  63. }
  64. prefers(p) {
  65. return this.rank(p.realId) < this.rank(this.otp.realId)
  66. }
  67. getNextCandidate() {
  68. if (this.matchCandidateIndex >= this.matchQueue.length) return null
  69. this.matchCandidateIndex = this.matchCandidateIndex + 1
  70. return this.matchQueue[this.matchCandidateIndex - 1]
  71. }
  72. engageTo(p) {
  73. if (p.otp) {
  74. p.otp.otp = null
  75. }
  76. if (this.otp) {
  77. this.otp.otp = null
  78. }
  79. p.otp = this
  80. p.fOrder.unshift(this)
  81. this.otp = p
  82. this.fOrder.unshift(p)
  83. console.log(
  84. 'partners pref: ',
  85. this.otp.matchQueue.map(f => f.realId).indexOf(this.realId) + 1,
  86. 'choice',
  87. )
  88. }
  89. }
  90. const magic = 1000
  91. const scoreResponses = (seeker, potentialMatch) => {
  92. if (seeker.responses.length != potentialMatch.responses.length)
  93. return {
  94. error: `complete responses for profile: ${seeker.profile_id} unqeual to profile: ${potentialMatch.profile_id} | ${seeker.responses.length}:${potentialMatch.responses.length}`,
  95. }
  96. const checkValCb = res => {
  97. const val = parseInt(res.val)
  98. return isNaN(val) ? 0 : val
  99. }
  100. return Math.floor(
  101. cosineSimilarity(
  102. seeker.responses.map(checkValCb),
  103. potentialMatch.responses.map(checkValCb),
  104. ) * magic,
  105. )
  106. }
  107. const filterByDistance = (profileList, max) => {
  108. return profileList.filter(profile => {
  109. const profileDistance = Math.floor(parseFloat(profile.distance) * 100)
  110. const adjustedMaxDistance = Math.floor(parseFloat(max) * 100)
  111. return profileDistance <= adjustedMaxDistance
  112. })
  113. }
  114. const scoreAll = (profileList, userProfile) => {
  115. return profileList.map(profile => {
  116. return {
  117. // Uncomment to return the whole profile
  118. // ...profile,
  119. profile_id: profile.profile_id,
  120. score: scoreResponses(userProfile, profile),
  121. distance: profile.distance,
  122. }
  123. })
  124. }
  125. /**
  126. * Grab the zip code string
  127. */
  128. const getZipCodeFromProfile = profile => {
  129. // There should only be one zip code entry per profile
  130. let zip = profile.responses.filter(
  131. response => response.response_key_id == 16,
  132. )[0]
  133. const responseIndexForZip = profile.responses.indexOf(zip)
  134. if (responseIndexForZip >= 0) {
  135. profile.responses.splice(responseIndexForZip, 1)
  136. }
  137. return zip.val
  138. }
  139. /**
  140. * Class to hold our retrieved profile information
  141. * in a convenient wrapper
  142. * !: This needs to match the responseSchema in profiles.js
  143. */
  144. class CompleteProfile {
  145. constructor(profile, type) {
  146. this.user_id = profile.user_id // int user_id
  147. this.profile_id = profile.profile_id // int profile_id
  148. this.responses = profile.responses // [] of all responses
  149. this.user_type = type
  150. }
  151. }
  152. module.exports = class ProfileService extends Schmervice.Service {
  153. constructor(...args) {
  154. super(...args)
  155. }
  156. /**
  157. * Internal method to get list of profile_ids for this user
  158. * @param {number} userId
  159. * @returns {Array} List of all profile_ids for user
  160. */
  161. async _getProfileIdsForUserId(userId) {
  162. const { Profile } = this.server.models()
  163. /** Grab every Profile associated with this id */
  164. const allProfiles = await Profile.query().where('user_id', userId)
  165. /** Copy a list of the just the Profiles */
  166. const profileIdsToGrab = allProfiles.map(profile => profile.profile_id)
  167. /** Uncomment to dedupe the list just in case */
  168. return [...new Set(profileIdsToGrab)]
  169. }
  170. async getCompleteProfilesFor(userId, type) {
  171. const { Profile } = this.server.models()
  172. const dedupedProfileIds = await this._getProfileIdsForUserId(userId)
  173. const profilesEntries = await Profile.query()
  174. .whereIn('profile_id', dedupedProfileIds)
  175. .withGraphFetched('responses')
  176. //** Get responses asociated with each profile_id */
  177. return profilesEntries.map(profile => {
  178. return new CompleteProfile(profile, type)
  179. })
  180. }
  181. /**
  182. * Save responses in a profile
  183. * @param {number} userId
  184. * @param {Array} responses
  185. * @returns {object}
  186. */
  187. async saveResponsesCreateProfileFor(userId, responses, txn) {
  188. const { Profile, Response } = this.server.models()
  189. const profile = await Profile.query(txn).insert({
  190. user_id: userId,
  191. })
  192. for (const responseToSave of responses) {
  193. const responseInfo = {
  194. profile_id: profile.id,
  195. response_key_id: responseToSave.response_key_id,
  196. val: responseToSave.val,
  197. }
  198. await Response.query(txn).insert(responseInfo)
  199. }
  200. //** Work around for HAPI returning profile_id as id */
  201. return { user_id: profile.user_id, profile_id: profile.id }
  202. }
  203. /** Update responses in place
  204. * @param {number} profileId
  205. * @param {Array} responses
  206. * @returns {Array} updated responses
  207. */
  208. async updateResponsesInProfile(profileId, responses, txn) {
  209. const { Response } = this.server.models()
  210. for (const responseToSave of responses) {
  211. await Response.query(txn)
  212. .update({
  213. response_id: responseToSave.response_id,
  214. profile_id: responseToSave.profile_id,
  215. response_key_id: responseToSave.response_key_id,
  216. val: responseToSave.val,
  217. })
  218. .where({
  219. profile_id: profileId,
  220. })
  221. .where({
  222. response_id: responseToSave.response_id,
  223. })
  224. }
  225. return await Response.query(txn).where({
  226. profile_id: profileId,
  227. })
  228. }
  229. /** Add response
  230. * @param {Object} response to save
  231. * @returns {null} updated responses
  232. * @returns {Array} updated responses
  233. */
  234. async saveResponseForProfile(profileId, responseToSave) {
  235. const { Response } = this.server.models()
  236. let allResponses = await Response.query().where({
  237. profile_id: profileId,
  238. })
  239. const matchingResponses = allResponses.filter(
  240. response =>
  241. response.response_key_id == responseToSave.response_key_id,
  242. )
  243. // ?:Maybe bad idea
  244. if (matchingResponses.length > 0) {
  245. return null
  246. }
  247. await await Response.query().insert(responseToSave)
  248. return allResponses
  249. }
  250. /**
  251. * Delete a profile
  252. * @param {number} userId
  253. * @param {number} profileId
  254. * @returns
  255. */
  256. async deleteProfile(userId, profileId) {
  257. const { Profile } = this.server.models()
  258. const dedupedGroupings = await this._getProfileIdsForUserId(userId)
  259. /** Do NOTHING if NOT in Grouping */
  260. if (!dedupedGroupings.includes(profileId)) return
  261. return await Profile.query().delete().where('profile_id', profileId)
  262. }
  263. /**
  264. * Score a profile
  265. * @param {number} profileId
  266. * @returns {Array} Ordered and scored Profiles
  267. */
  268. async scoreProfilesFor(profileId, maxDistance, distanceUnit) {
  269. const { Profile } = this.server.models()
  270. // Our User Profile to score for
  271. const userProfile = await Profile.query()
  272. .findOne('profile_id', profileId)
  273. .withGraphFetched('responses')
  274. .withGraphFetched('user')
  275. // Move unneeded responses
  276. const userZip = getZipCodeFromProfile(userProfile)
  277. // Find all Profiles that are NOT of our userProfile.type
  278. // ie. If userProfile.type == seeker, then find: poster
  279. let profileIdsOfOppositeType = await Profile.query()
  280. .withGraphFetched('responses')
  281. .withGraphFetched('user')
  282. // TODO: Let Objection optimize this
  283. const isPosterOpposite = userProfile.user.is_poster == 1 ? 0 : 1
  284. profileIdsOfOppositeType = profileIdsOfOppositeType.filter(
  285. profile => profile.user.is_poster == isPosterOpposite,
  286. )
  287. const profilePlusDistance = await Promise.all(
  288. profileIdsOfOppositeType.map(async profile => {
  289. const targetZip = getZipCodeFromProfile(profile)
  290. const distance = await this._compareDistance(
  291. userZip,
  292. targetZip,
  293. distanceUnit,
  294. )
  295. return {
  296. ...profile,
  297. distance: [distance.toFixed(2), distanceUnit],
  298. }
  299. }),
  300. )
  301. const distanceFilteredProfiles = filterByDistance(
  302. profilePlusDistance,
  303. maxDistance,
  304. )
  305. const scoredProfilesWithDistance = scoreAll(
  306. distanceFilteredProfiles,
  307. userProfile,
  308. )
  309. // Order by score
  310. return scoredProfilesWithDistance.sort((a, b) => a.score - b.score)
  311. }
  312. async calcMatches() {
  313. const { Profile } = this.server.models()
  314. // Grab all profiles with matchQueues
  315. let allProfiles = await Profile.query().withGraphFetched('user')
  316. // !:FAKE Score everyone
  317. // const profileIds = allProfiles.map(profile => profile.profile_id)
  318. const scoredProfileQueuesById = {}
  319. for (let profile of allProfiles) {
  320. const profileQueue = await this.scoreProfilesFor(
  321. profile.profile_id,
  322. 10000,
  323. 'mile',
  324. )
  325. scoredProfileQueuesById[profile.profile_id] = profileQueue.map(
  326. profile => profile.profile_id,
  327. )
  328. }
  329. const allProfileFacadesWithQueue = allProfiles.map(profile => {
  330. const profileFacadeQueue = scoredProfileQueuesById[
  331. profile.profile_id
  332. ].map(id => {
  333. const subQueue = scoredProfileQueuesById[id].map(
  334. id => new ProfileFacade(id),
  335. )
  336. return new ProfileFacade(id, subQueue)
  337. })
  338. return new ProfileFacade(profile.profile_id, profileFacadeQueue)
  339. })
  340. // // ! FAKE -- END
  341. const seekerIds = allProfiles
  342. .filter(profile => profile.user.is_poster == 0)
  343. .map(profile => profile.profile_id)
  344. const posterIds = allProfiles
  345. .filter(profile => profile.user.is_poster == 1)
  346. .map(profile => profile.profile_id)
  347. const yins = seekerIds.map(id => allProfileFacadesWithQueue[id])
  348. const yangs = posterIds.map(id => allProfileFacadesWithQueue[id])
  349. runMatch(yins, yangs)
  350. return yins
  351. }
  352. /**
  353. * Use the db for zipcode info
  354. * @param {string} zipCode
  355. * @param {object}
  356. */
  357. async _latLonForZip(zipCode) {
  358. const { ZipCode } = this.server.models()
  359. const zipInfo = await ZipCode.query().findOne(
  360. 'zip_code_id',
  361. parseInt(zipCode),
  362. )
  363. if (!zipInfo) {
  364. console.log(zipCode)
  365. }
  366. return {
  367. latitude: parseFloat(zipInfo.latitude),
  368. longitude: parseFloat(zipInfo.longitude),
  369. }
  370. }
  371. /**
  372. * Get the distance between two zipcodes
  373. * using the haversine formula
  374. * @param {string} start_zip
  375. * @param {string} end_zip
  376. * @param {number} distance in miles
  377. */
  378. async _compareDistance(start_zip, end_zip, distanceUnit) {
  379. if (!start_zip || !end_zip) return
  380. const start = await this._latLonForZip(start_zip)
  381. const end = await this._latLonForZip(end_zip)
  382. return haversine(start, end, { unit: distanceUnit })
  383. }
  384. }