aboutsummaryrefslogtreecommitdiff
path: root/generalresearch/managers/thl/profiling
diff options
context:
space:
mode:
Diffstat (limited to 'generalresearch/managers/thl/profiling')
-rw-r--r--generalresearch/managers/thl/profiling/__init__.py0
-rw-r--r--generalresearch/managers/thl/profiling/question.py157
-rw-r--r--generalresearch/managers/thl/profiling/schema.py75
-rw-r--r--generalresearch/managers/thl/profiling/uqa.py211
-rw-r--r--generalresearch/managers/thl/profiling/user_upk.py343
5 files changed, 786 insertions, 0 deletions
diff --git a/generalresearch/managers/thl/profiling/__init__.py b/generalresearch/managers/thl/profiling/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/generalresearch/managers/thl/profiling/__init__.py
diff --git a/generalresearch/managers/thl/profiling/question.py b/generalresearch/managers/thl/profiling/question.py
new file mode 100644
index 0000000..1ad27ac
--- /dev/null
+++ b/generalresearch/managers/thl/profiling/question.py
@@ -0,0 +1,157 @@
+import random
+import threading
+from typing import Collection, List, Tuple
+
+from cachetools import cached, TTLCache
+from pydantic import ValidationError
+
+from generalresearch.decorators import LOG
+from generalresearch.managers.base import PostgresManager
+from generalresearch.models.thl.profiling.upk_question import (
+ UpkQuestion,
+ UPKImportance,
+)
+
+
+class QuestionManager(PostgresManager):
+
+ def get_multi_upk(self, question_ids: Collection[str]) -> List[UpkQuestion]:
+ query = """
+ SELECT data, property_code, explanation_template, explanation_fragment_template
+ FROM marketplace_question
+ WHERE id = ANY(%(question_ids)s);
+ """
+ res = self.pg_config.execute_sql_query(
+ query, {"question_ids": list(question_ids)}
+ )
+ for x in res:
+ x["data"]["ext_question_id"] = x["property_code"]
+ x["data"]["explanation_template"] = x["explanation_template"]
+ x["data"]["explanation_fragment_template"] = x[
+ "explanation_fragment_template"
+ ]
+ x["data"].pop("categories", None)
+ return [UpkQuestion.model_validate(x["data"]) for x in res]
+
+ @cached(
+ cache=TTLCache(maxsize=256, ttl=3600 + random.randint(-900, 900)),
+ lock=threading.Lock(),
+ info=True,
+ )
+ def get_questions_ranked(
+ self, country_iso: str, language_iso: str
+ ) -> List[UpkQuestion]:
+ query = """
+ SELECT data, property_code, explanation_template, explanation_fragment_template
+ FROM marketplace_question
+ WHERE country_iso = %(country_iso)s
+ AND language_iso = %(language_iso)s
+ AND property_code NOT LIKE 'gr:%%'
+ AND property_code NOT LIKE 'g:%%'
+ AND is_live
+ """
+ res = self.pg_config.execute_sql_query(
+ query=query,
+ params={"country_iso": country_iso, "language_iso": language_iso},
+ )
+ qs: List[UpkQuestion] = []
+ for x in res:
+ x["data"]["ext_question_id"] = x["property_code"]
+ x["data"]["explanation_template"] = x["explanation_template"]
+ x["data"]["explanation_fragment_template"] = x[
+ "explanation_fragment_template"
+ ]
+ x["data"].pop("categories", None)
+ q = UpkQuestion.model_validate(x["data"])
+ if not q.importance:
+ q.importance = UPKImportance(
+ task_count=x["data"].get("task_count", 0),
+ task_score=x["data"].get("task_score", 0),
+ )
+ qs.append(q)
+
+ res = sorted(qs, key=lambda x: x.importance.task_score, reverse=True)
+ return res
+
+ @cached(
+ cache=TTLCache(maxsize=256, ttl=3600 + random.randint(-900, 900)),
+ lock=threading.Lock(),
+ info=True,
+ )
+ def lookup_by_property(
+ self, property_code: str, country_iso: str, language_iso: str
+ ) -> UpkQuestion:
+ query = f"""
+ SELECT data, property_code, explanation_template, explanation_fragment_template
+ FROM marketplace_question
+ WHERE property_code = %(property_code)s
+ AND country_iso = %(country_iso)s
+ AND language_iso = %(language_iso)s
+ LIMIT 2;
+ """
+ params = {
+ "property_code": property_code,
+ "country_iso": country_iso,
+ "language_iso": language_iso,
+ }
+ res = self.pg_config.execute_sql_query(query=query, params=params)
+ assert len(res) == 1, f"expected 1, got {len(res)} results"
+ x = res[0]
+ x["data"]["ext_question_id"] = x["property_code"]
+ x["data"]["explanation_template"] = x["explanation_template"]
+ x["data"]["explanation_fragment_template"] = x["explanation_fragment_template"]
+ x["data"].pop("categories", None)
+ return UpkQuestion.model_validate(x["data"])
+
+ def filter_by_property(
+ self, lookup: Collection[Tuple[str, str, str]]
+ ) -> List[UpkQuestion]:
+ """
+ lookup is [(property_code, country_iso, language_iso)]
+ """
+ where_str = " OR ".join(
+ "(property_code = %s AND country_iso = %s AND language_iso = %s)"
+ for _ in lookup
+ )
+ query = f"""
+ SELECT data, property_code, explanation_template, explanation_fragment_template
+ FROM marketplace_question
+ WHERE {where_str}
+ """
+ flat_params = [item for tup in lookup for item in tup]
+ res = self.pg_config.execute_sql_query(query, params=flat_params)
+ for x in res:
+ x["data"]["ext_question_id"] = x["property_code"]
+ x["data"]["explanation_template"] = x["explanation_template"]
+ x["data"]["explanation_fragment_template"] = x[
+ "explanation_fragment_template"
+ ]
+ x["data"].pop("categories", None)
+ res2 = []
+ for x in res:
+ try:
+ res2.append(UpkQuestion.model_validate(x["data"]))
+ except ValidationError as e:
+ LOG.warning(e)
+ return res2
+
+ def update_question_explanation(self, q: UpkQuestion):
+ # Assuming the question already exists in the db, and we're updating
+ # the fields explanation_template and explanation_fragment_template
+ assert q.id, "q.id must be set"
+ query = """
+ UPDATE marketplace_question
+ SET explanation_template = %(explanation_template)s,
+ explanation_fragment_template = %(explanation_fragment_template)s
+ WHERE id = %(id)s;"""
+ params = {
+ "id": q.id,
+ "explanation_template": q.explanation_template,
+ "explanation_fragment_template": q.explanation_fragment_template,
+ }
+ with self.pg_config.make_connection() as conn:
+ with conn.cursor() as c:
+ c.execute(query, params)
+ assert c.rowcount == 1
+ conn.commit()
+ return None
diff --git a/generalresearch/managers/thl/profiling/schema.py b/generalresearch/managers/thl/profiling/schema.py
new file mode 100644
index 0000000..581270b
--- /dev/null
+++ b/generalresearch/managers/thl/profiling/schema.py
@@ -0,0 +1,75 @@
+from threading import RLock
+from typing import List
+from uuid import UUID
+
+from cachetools import cached, TTLCache
+
+from generalresearch.managers.base import PostgresManager
+from generalresearch.models.thl.profiling.upk_property import (
+ UpkProperty,
+)
+
+
+class UpkSchemaManager(PostgresManager):
+
+ @cached(cache=TTLCache(maxsize=1, ttl=18 * 60), lock=RLock())
+ def get_props_info(self) -> List[UpkProperty]:
+ query = """
+ SELECT
+ p.id AS property_id,
+ p.label AS property_label,
+ p.cardinality,
+ p.prop_type,
+ pc.country_iso,
+ pc.gold_standard,
+ allowed_items.allowed_items,
+ cats.categories
+ FROM marketplace_property p
+ JOIN marketplace_propertycountry pc
+ ON p.id = pc.property_id
+ -- allowed_items: all items for this property + country
+ LEFT JOIN LATERAL (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'id', mi.id,
+ 'label', mi.label,
+ 'description', mi.description
+ ) ORDER BY mi.label
+ ) AS allowed_items
+ FROM marketplace_propertyitemrange pir
+ JOIN marketplace_item mi
+ ON pir.item_id = mi.id
+ WHERE pir.property_id = p.id
+ AND pir.country_iso = pc.country_iso
+ ) allowed_items ON TRUE
+
+ -- categories: all categories for this property
+ LEFT JOIN LATERAL (
+ SELECT
+ jsonb_agg(
+ jsonb_build_object(
+ 'uuid', cat.uuid,
+ 'label', cat.label,
+ 'path', cat.path,
+ 'adwords_vertical_id', cat.adwords_vertical_id
+ )
+ ) AS categories
+ FROM marketplace_propertycategoryassociation pcat
+ JOIN marketplace_category cat ON pcat.category_id = cat.id
+ WHERE pcat.property_id = p.id
+ ) AS cats ON TRUE;
+ """
+ res = self.pg_config.execute_sql_query(query)
+ for x in res:
+ for c in x["categories"]:
+ c["uuid"] = UUID(c["uuid"]).hex
+ if x["allowed_items"]:
+ for c in x["allowed_items"]:
+ c["id"] = UUID(c["id"]).hex
+ return [UpkProperty.model_validate(x) for x in res]
+
+ def get_props_info_for_country(self, country_iso: str) -> List[UpkProperty]:
+ assert country_iso.lower() == country_iso
+ res = self.get_props_info()
+ res = [x for x in res if x.country_iso == country_iso].copy()
+ return res
diff --git a/generalresearch/managers/thl/profiling/uqa.py b/generalresearch/managers/thl/profiling/uqa.py
new file mode 100644
index 0000000..6800d32
--- /dev/null
+++ b/generalresearch/managers/thl/profiling/uqa.py
@@ -0,0 +1,211 @@
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import Collection, List, Optional
+
+from generalresearch.managers.base import PostgresManagerWithRedis
+from generalresearch.models.thl.profiling.user_question_answer import (
+ DUMMY_UQA,
+ UserQuestionAnswer,
+)
+from generalresearch.models.thl.user import User
+
+logger = logging.getLogger()
+
+
+class UQAManager(PostgresManagerWithRedis):
+
+ CACHE_PREFIX = "thl-grpc:uqa-cache-v2"
+
+ def redis_key(self, user: User) -> str:
+ return f"{self.CACHE_PREFIX}:{user.user_id}"
+
+ def redis_lock_key(self, user: User) -> str:
+ return self.redis_key(user) + ":lock"
+
+ def update_cache(
+ self,
+ user: User,
+ uqas: List[UserQuestionAnswer],
+ ):
+ """
+ Adds new answers to the redis cache for this user. If the cache
+ doesn't exist, does a db query to populate the cache.
+ """
+ REDIS = self.redis_client
+ redis_key = self.redis_key(user)
+ redis_lock = self.redis_lock_key(user)
+ json_answers = [uqa.model_dump_json() for uqa in uqas]
+ with REDIS.lock(redis_lock, timeout=2):
+ # Append the new answers into the cache.
+ res = REDIS.rpushx(redis_key, *json_answers)
+ if res == 0:
+ # If the cache doesn't exist, we need to make it from scratch
+ uqas = self.get_from_db(user)
+ all_json_answers = {uqa.model_dump_json() for uqa in uqas}
+ # And then make sure thew new answers are in it (b/c we query from the RR)
+ all_json_answers.update(set(json_answers))
+ REDIS.rpush(redis_key, *all_json_answers)
+ REDIS.expire(redis_key, 3600)
+ self.clear_user_demographic_cache(user)
+ return res
+
+ def clear_cache(self, user: User) -> None:
+ self.redis_client.delete(self.redis_key(user))
+
+ def recreate_cache(self, user: User) -> Collection[UserQuestionAnswer]:
+ REDIS = self.redis_client
+ redis_key = self.redis_key(user)
+ redis_lock = self.redis_lock_key(user)
+ with REDIS.lock(redis_lock, timeout=2):
+ # once we've acquired the lock, we need to check again if someone else just made it
+ if REDIS.exists(redis_key):
+ values = REDIS.lrange(redis_key, 0, -1)
+ uqas = [UserQuestionAnswer.model_validate_json(x) for x in values]
+ else:
+ uqas = self.get_from_db(user)
+ if not uqas:
+ # We can't set this to an empty list in redis (no
+ # difference between None and []).
+ #
+ # I don't know what is the best thing to do here, so I'm
+ # gonna push in a "dummy" UQA, just to indicate this has
+ # been set and we shouldn't query the db anymore unless
+ # something changes...
+ REDIS.rpush(redis_key, DUMMY_UQA.model_dump_json())
+ else:
+ REDIS.rpush(redis_key, *[uqa.model_dump_json() for uqa in uqas])
+ REDIS.expire(redis_key, 3600 * 3 * 24)
+
+ uqas = self._dedupe_and_clean_uqas(uqas)
+ self.clear_user_demographic_cache(user)
+ return uqas
+
+ def _dedupe_and_clean_uqas(
+ self,
+ uqas: List[UserQuestionAnswer],
+ ) -> List[UserQuestionAnswer]:
+ # Remove anything older than 30 days
+ uqas = [uqa for uqa in uqas if not uqa.is_stale()]
+
+ # Dedupe, latest answer per question
+ new_uqas = set()
+ seen_question_ids = set()
+ uqas = sorted(uqas, key=lambda x: x.timestamp, reverse=True)
+ for uqa in uqas:
+ if uqa.question_id not in seen_question_ids:
+ seen_question_ids.add(uqa.question_id)
+ new_uqas.add(uqa)
+
+ return sorted(new_uqas, key=lambda x: x.timestamp, reverse=True)
+
+ def get(self, user: User) -> List[UserQuestionAnswer]:
+ uqas = self.get_from_cache(user=user)
+
+ if uqas is None:
+ uqas = self.recreate_cache(user)
+ return self._dedupe_and_clean_uqas(uqas)
+
+ def get_from_cache(self, user: User) -> Optional[List[UserQuestionAnswer]]:
+ redis_key = self.redis_key(user)
+
+ # Do the exists check and the list retrieval in a single transaction
+ with self.redis_client.pipeline() as pipe:
+ exists = pipe.exists(redis_key)
+ if exists:
+ pipe.lrange(redis_key, 0, -1)
+ result = pipe.execute()
+ exists = result[0]
+ values = result[1]
+ if not exists:
+ logger.info(f"{redis_key} doesn't exist")
+ return None
+ uqas = [UserQuestionAnswer.model_validate_json(x) for x in values]
+ logger.info(f"{redis_key} exists")
+ return uqas
+
+ def get_from_db(self, user: User) -> List[UserQuestionAnswer]:
+ logger.info(f"get_uqa_from_db: {user.user_id}")
+ # Only store the latest row per question_id. We don't need it multiple times.
+ since = datetime.now(tz=timezone.utc) - timedelta(days=30)
+
+ # We CAN use the RR, b/c either
+ # 1) the cache expired and the user hasn't sent an answer recently
+ # or 2) The user just sent an answer, so we'll make sure it gets put into the results
+ # after this query runs.
+ query = f"""
+ WITH ranked AS (
+ SELECT
+ uqa.*,
+ ROW_NUMBER() OVER (
+ PARTITION BY question_id
+ ORDER BY created DESC
+ ) AS rn
+ FROM marketplace_userquestionanswer uqa
+ WHERE uqa.user_id = %(user_id)s
+ AND uqa.created > %(since)s
+ )
+ SELECT
+ r.question_id::uuid,
+ r.created::timestamptz AS timestamp,
+ r.calc_answer::jsonb AS calc_answers,
+ r.answer::jsonb,
+ r.user_id,
+ mq.property_code,
+ mq.country_iso,
+ mq.language_iso
+ FROM ranked r
+ JOIN marketplace_question mq
+ ON r.question_id = mq.id
+ WHERE rn = 1
+ ORDER BY r.created;
+ """
+ res = self.pg_config.execute_sql_query(
+ query=query,
+ params={"user_id": user.user_id, "since": since},
+ )
+ uqas = [UserQuestionAnswer.model_validate(x) for x in res]
+ return uqas
+
+ def clear_user_demographic_cache(
+ self,
+ user: User,
+ ) -> None:
+ # this will get regenerated by thl-grpc when an offerwall call is made
+ redis_key = f"thl-grpc:user-demographics:{user.user_id}"
+ self.redis_client.delete(redis_key)
+
+ return None
+
+ def create(
+ self,
+ user: User,
+ uqas: List[UserQuestionAnswer],
+ session_id: Optional[str] = None,
+ ):
+ for uqa in uqas:
+ if uqa.user_id is None:
+ uqa.user_id = user.user_id
+ else:
+ assert uqa.user_id == user.user_id
+ self.create_in_db(uqas=uqas, session_id=session_id)
+ self.update_cache(user=user, uqas=uqas)
+ return None
+
+ def create_in_db(
+ self, uqas: List[UserQuestionAnswer], session_id: Optional[str] = None
+ ):
+ values = [uqa.model_dump_mysql(session_id=session_id) for uqa in uqas]
+ query = """
+ INSERT INTO marketplace_userquestionanswer
+ (created, session_id, answer, question_id, user_id, calc_answer)
+ VALUES (
+ %(created)s, %(session_id)s, %(answer)s,
+ %(question_id)s, %(user_id)s, %(calc_answer)s
+ );
+ """
+
+ with self.pg_config.make_connection() as conn:
+ with conn.cursor() as c:
+ c.executemany(query=query, params_seq=values)
+ conn.commit()
+ return None
diff --git a/generalresearch/managers/thl/profiling/user_upk.py b/generalresearch/managers/thl/profiling/user_upk.py
new file mode 100644
index 0000000..4449afa
--- /dev/null
+++ b/generalresearch/managers/thl/profiling/user_upk.py
@@ -0,0 +1,343 @@
+import json
+from collections import defaultdict
+from datetime import timedelta, datetime, timezone
+from typing import Dict, Union, Set, List, Collection, Optional, Tuple
+from uuid import UUID
+
+from psycopg import Cursor
+
+from generalresearch.managers.base import (
+ Permission,
+ PostgresManagerWithRedis,
+)
+from generalresearch.managers.thl.profiling.schema import UpkSchemaManager
+from generalresearch.models.thl.profiling.upk_property import (
+ Cardinality,
+ PropertyType,
+ UpkProperty,
+)
+from generalresearch.models.thl.profiling.upk_question_answer import (
+ UpkQuestionAnswer,
+)
+from generalresearch.pg_helper import PostgresConfig
+from generalresearch.redis_helper import RedisConfig
+
+
+class UserUpkManager(PostgresManagerWithRedis):
+ def __init__(
+ self,
+ pg_config: PostgresConfig,
+ redis_config: RedisConfig,
+ permissions: Collection[Permission] = None,
+ cache_prefix: Optional[str] = None,
+ ):
+ super().__init__(
+ pg_config=pg_config,
+ redis_config=redis_config,
+ permissions=permissions,
+ cache_prefix=cache_prefix,
+ )
+ self.upk_schema_manager = UpkSchemaManager(pg_config=pg_config)
+
+ def clear_upk_cache(self, user_id: int) -> None:
+ self.redis_client.delete(f"thl-grpc:user-upk:{user_id}")
+ return None
+
+ def get_user_upk(self, user_id: int) -> List[UpkQuestionAnswer]:
+ res = self.redis_client.get(f"thl-grpc:user-upk:{user_id}")
+ if res:
+ return [UpkQuestionAnswer.model_validate(x) for x in json.loads(res)]
+ res = self.get_user_upk_mysql(user_id)
+ value = json.dumps([x.model_dump(mode="json") for x in res])
+ self.redis_client.set(f"thl-grpc:user-upk:{user_id}", value, ex=60 * 60 * 24)
+ return res
+
+ def get_user_upk_mysql(self, user_id: int) -> List[UpkQuestionAnswer]:
+ since = datetime.now(tz=timezone.utc) - timedelta(days=89)
+
+ query = """
+ SELECT
+ x.property_id,
+ mp.label AS property_label,
+ mi.id AS item_id,
+ mi.label AS item_label,
+ mp.prop_type,
+ mp.cardinality,
+ x.created,
+ x.value_num,
+ x.value_text,
+ x.country_iso
+ FROM (
+ SELECT
+ property_id,
+ value::uuid AS item_id,
+ NULL::numeric AS value_num,
+ NULL::text AS value_text,
+ created,
+ country_iso
+ FROM marketplace_userprofileknowledgeitem AS upki
+ WHERE user_id = %(user_id)s AND created > %(since)s
+
+ UNION ALL
+
+ SELECT
+ property_id,
+ NULL::uuid AS item_id,
+ value::numeric AS value_num,
+ NULL::text AS value_text,
+ created,
+ country_iso
+ FROM marketplace_userprofileknowledgenumerical
+ WHERE user_id = %(user_id)s AND created > %(since)s
+
+ UNION ALL
+
+ SELECT
+ property_id,
+ NULL::uuid AS item_id,
+ NULL::numeric AS value_num,
+ value::text AS value_text,
+ created,
+ country_iso
+ FROM marketplace_userprofileknowledgetext
+ WHERE user_id = %(user_id)s AND created > %(since)s
+ ) x
+ JOIN marketplace_property mp
+ ON x.property_id = mp.id
+ LEFT JOIN marketplace_item mi
+ ON x.item_id = mi.id::uuid;
+ """
+ params = {"user_id": user_id, "since": since}
+ res = self.pg_config.execute_sql_query(query, params=params)
+ for x in res:
+ x["user_id"] = user_id
+ x["property_id"] = UUID(x["property_id"]).hex
+ if x["item_id"]:
+ x["item_id"] = UUID(x["item_id"]).hex
+ return [UpkQuestionAnswer.model_validate(x) for x in res]
+
+ def get_user_upk_simple(
+ self, user_id, country_iso="us"
+ ) -> Dict[str, Union[Set[str], str, float]]:
+ res = self.get_user_upk(user_id=user_id)
+ res = [x for x in res if x.country_iso == country_iso]
+ d: Dict[str, Union[Set[str], str, float]] = defaultdict(set)
+ for x in res:
+ if x.cardinality == Cardinality.ZERO_OR_ONE:
+ d[x.property_label] = x.value
+ else:
+ d[x.property_label].add(x.value)
+ return dict(d)
+
+ def get_age_gender(
+ self, user_id, country_iso="us"
+ ) -> Tuple[Optional[int], Optional[str]]:
+ # Returns an integer year for age, and {'male', 'female', 'other_gender'}
+ d = self.get_user_upk_simple(user_id, country_iso)
+ age = d.get("age_in_years")
+ if age is not None:
+ age = int(age)
+ gender = d.get("gender")
+ return age, gender
+
+ def get_upk_schema(self, country_iso: str) -> List[UpkProperty]:
+ return self.upk_schema_manager.get_props_info_for_country(
+ country_iso=country_iso
+ )
+
+ def populate_user_upk_from_dict(self, upk_ans_dict):
+
+ country_isos = {x["country_iso"] for x in upk_ans_dict}
+ assert len(country_isos) == 1
+ country_iso = list(country_isos)[0]
+ for x in upk_ans_dict:
+ x["pred"] = x["pred"].replace("gr:", "")
+ x["obj"] = x["obj"].replace("gr:", "")
+ prop_labels = {x["pred"] for x in upk_ans_dict}
+
+ props = self.get_upk_schema(country_iso=country_iso)
+ props = [
+ x
+ for x in props
+ if x.property_label in prop_labels or x.property_id in prop_labels
+ ]
+ label_to_prop = {x.property_label: x for x in props}
+ id_to_prop = {x.property_id: x for x in props}
+
+ for x in upk_ans_dict:
+ prop = label_to_prop.get(x["pred"]) or id_to_prop[x["pred"]]
+ x["property_id"] = prop.property_id
+ x["property_label"] = prop.property_label
+ x["prop_type"] = prop.prop_type
+ x["cardinality"] = prop.cardinality
+ x["created"] = x["timestamp"]
+ if prop.prop_type == PropertyType.UPK_ITEM:
+ if x["obj"] in prop.allowed_items_by_id:
+ x["item_label"] = prop.allowed_items_by_id[x["obj"]].label
+ x["item_id"] = x["obj"]
+ else:
+ x["item_label"] = x["obj"]
+ x["item_id"] = prop.allowed_items_by_label[x["obj"]].id
+ elif prop.prop_type == PropertyType.UPK_TEXT:
+ x["value_text"] = x["obj"]
+ elif prop.prop_type == PropertyType.UPK_NUMERICAL:
+ x["value_num"] = x["obj"]
+
+ upk_ans = [UpkQuestionAnswer.model_validate(x) for x in upk_ans_dict]
+ return upk_ans
+
+ def upsert_user_profile_knowledge(self, c: Cursor, row: UpkQuestionAnswer):
+ prop_type_table = {
+ PropertyType.UPK_ITEM: "marketplace_userprofileknowledgeitem",
+ PropertyType.UPK_NUMERICAL: "marketplace_userprofileknowledgenumerical",
+ PropertyType.UPK_TEXT: "marketplace_userprofileknowledgetext",
+ }
+ prop_type_value = {
+ PropertyType.UPK_ITEM: "item_id",
+ PropertyType.UPK_NUMERICAL: "value_num",
+ PropertyType.UPK_TEXT: "value_text",
+ }
+ table = prop_type_table[row.prop_type]
+ value = prop_type_value[row.prop_type]
+ args = row.model_dump_mysql()
+
+ c.execute(
+ f"""
+ SELECT id FROM {table}
+ WHERE user_id = %(user_id)s AND property_id = %(property_id)s AND country_iso = %(country_iso)s
+ LIMIT 1""",
+ args,
+ )
+ existing = c.fetchone()
+
+ if existing:
+ c.execute(
+ f"""
+ UPDATE {table}
+ SET value = %({value})s,
+ created = %(created)s,
+ question_id = %(question_id)s,
+ session_id = %(session_id)s
+ WHERE user_id = %(user_id)s AND
+ property_id = %(property_id)s AND
+ country_iso = %(country_iso)s
+ """,
+ args,
+ )
+ else:
+ c.execute(
+ f"""
+ INSERT INTO {table}
+ (property_id, value, created, country_iso, question_id, user_id, session_id)
+ VALUES (%(property_id)s, %({value})s, %(created)s, %(country_iso)s,
+ %(question_id)s, %(user_id)s, %(session_id)s)
+ """,
+ args,
+ )
+
+ def upsert_user_profile_knowledge_multi_item(
+ self, c: Cursor, row: UpkQuestionAnswer
+ ):
+ args = row.model_dump_mysql()
+
+ c.execute(
+ """
+ SELECT id FROM marketplace_userprofileknowledgeitem
+ WHERE user_id = %(user_id)s AND
+ property_id = %(property_id)s AND
+ country_iso = %(country_iso)s AND
+ value = %(item_id)s
+ LIMIT 1""",
+ args,
+ )
+ existing = c.fetchone()
+
+ if existing:
+ c.execute(
+ """
+ UPDATE marketplace_userprofileknowledgeitem
+ SET created = %(created)s,
+ question_id = %(question_id)s,
+ session_id = %(session_id)s
+ WHERE user_id = %(user_id)s AND
+ property_id = %(property_id)s AND
+ country_iso = %(country_iso)s AND
+ value = %(item_id)s
+ """,
+ args,
+ )
+ else:
+ c.execute(
+ """
+ INSERT INTO marketplace_userprofileknowledgeitem
+ (property_id, value, created, country_iso, question_id, user_id, session_id)
+ VALUES (%(property_id)s, %(item_id)s, %(created)s, %(country_iso)s,
+ %(question_id)s, %(user_id)s, %(session_id)s)
+ """,
+ args,
+ )
+
+ def delete_user_profile_knowledge_multi_item(
+ self, c: Cursor, row: UpkQuestionAnswer
+ ) -> None:
+ args = row.model_dump_mysql()
+ c.execute(
+ """
+ DELETE FROM marketplace_userprofileknowledgeitem
+ WHERE user_id = %(user_id)s AND
+ property_id = %(property_id)s AND
+ country_iso = %(country_iso)s AND
+ value = %(item_id)s
+ """,
+ args,
+ )
+
+ return None
+
+ def set_user_upk(self, upk_ans: List[UpkQuestionAnswer]):
+ user_id = {x.user_id for x in upk_ans}
+ assert len(user_id) == 1, "only run for 1 user at a time"
+ user_id = list(user_id)[0]
+
+ curr_upk = self.get_user_upk(user_id=user_id)
+ curr_upk_simple = self.get_user_upk_simple(user_id=user_id)
+
+ new_upk_simple = defaultdict(set)
+ delete_items = set()
+ upk_multi = list()
+ delete_upk_multi = list()
+ for x in upk_ans:
+ # For zero or more (multiple values) We want all values to equal these.
+ # Might involve deleting values if they exist and are not in upk_ans
+ if (
+ x.cardinality == Cardinality.ZERO_OR_MORE
+ and x.prop_type != PropertyType.UPK_ITEM
+ ):
+ raise ValueError("unsupported")
+ if (
+ x.cardinality == Cardinality.ZERO_OR_MORE
+ and x.prop_type == PropertyType.UPK_ITEM
+ ):
+ new_upk_simple[x.property_label].add(x.item_label)
+ upk_multi.append(x)
+ for k, v in new_upk_simple.items():
+ prop_delete_labels = curr_upk_simple.get(k, set()) - v
+ for x in prop_delete_labels:
+ delete_items.add((k, x))
+ if delete_items:
+ for x in curr_upk:
+ if (x.property_label, x.item_label) in delete_items:
+ delete_upk_multi.append(x)
+
+ with self.pg_config.make_connection() as conn:
+ with conn.cursor() as c:
+ for x in upk_multi:
+ self.upsert_user_profile_knowledge_multi_item(c, row=x)
+ for x in delete_upk_multi:
+ self.delete_user_profile_knowledge_multi_item(c, row=x)
+ for x in upk_ans:
+ if x.cardinality == Cardinality.ZERO_OR_ONE:
+ # If the cardinality is 0 or 1, we're inserting or updating the answer
+ self.upsert_user_profile_knowledge(c, x)
+ conn.commit()
+ self.clear_upk_cache(user_id=user_id)