Cómo ajustar los puntos de gamificación sin actualizar retroactivamente los valores de puntos de acciones anteriores

Declaración del problema

Si utilizas el plugin de gamificación de Discourse para otorgar puntos a los usuarios por contribuir a tu comunidad, es posible que hayas detectado la necesidad de ajustar los valores de los puntos según las tendencias emergentes. En la Comunidad de desarrolladores de SailPoint, utilizamos el plugin de Gamificación para impulsar nuestro programa de embajadores. Los usuarios que realizan contribuciones valiosas a nuestra comunidad ganan puntos, los cuales a su vez se utilizan para determinar el nivel de beneficios que reciben. A medida que nuestra comunidad ha crecido y el número de contribuciones ha aumentado drásticamente, determinamos que los valores originales que asignamos a ciertos tipos de contribuciones necesitaban ser ajustados. Ajustar los valores de los puntos funciona bien si nunca realizas una recálculo de tus puntos, pero a menudo tenemos que recalcular los puntos cuando fusionamos usuarios o cuando utilizamos la API de gamificación externa para otorgar puntos por contribuciones pasadas. Si modificas los valores de los puntos de gamificación y realizas un recálculo, las contribuciones anteriores que se puntuaron con los valores antiguos ahora se puntuarán con los nuevos valores. Esto es un problema si deseas que los nuevos valores de puntos se asignen a nuevas contribuciones, mientras que las contribuciones antiguas permanezcan con el mismo valor. En esta guía, discutiré una solución que he creado para asegurar que los puntos anteriores de tus usuarios no se alteren cuando cambies los valores de los puntos y ejecutes un recálculo.

Utilizando el Explorador de Datos para calcular los puntos actuales y propuestos

Importar la consulta SQL

El primer paso en este proceso es importar la siguiente consulta SQL en tu plugin de Explorador de Datos. Esta consulta SQL fue modificada a partir de la consulta SQL original para incluir paginación, agregar columnas adicionales para user_name y name, y ordenar por user_id. Esta consulta generará una tabla de puntuaciones de usuarios para un rango de tiempo determinado y utilizando los valores de puntuación dados.

-- [params]
-- date :start_date
-- date :end_date
-- int :day_visited_score_value = 0
-- int :time_read_score_value = 0
-- int :posts_read_score_value = 0
-- int :posts_created_score_value = 6
-- int :topics_created_score_value = 0
-- int :likes_received_score_value = 3
-- int :likes_given_score_value = 0
-- int :solutions_score_value = 60
-- int :flag_created_score_value = 6
-- int :user_invited_score_value = 0
-- int :limit = 1000
-- int :page = 0

WITH visits AS (
  SELECT
    uv.user_id,
    COUNT(*) AS user_visits,
    COUNT(*) * :day_visited_score_value AS visits_score
  FROM user_visits uv
  WHERE uv.visited_at BETWEEN :start_date AND :end_date
  GROUP BY uv.user_id
),

     time_read AS (
       SELECT
         uv.user_id,
         SUM(uv.time_read) /3600 AS time_read,
         SUM(uv.time_read) /3600 * :time_read_score_value AS time_read_score
       FROM user_visits uv
       WHERE uv.visited_at BETWEEN :start_date AND :end_date
         AND uv.time_read >= 60
       GROUP BY uv.user_id
     ),

     posts_read AS (
       SELECT
         uv.user_id,
         SUM(uv.posts_read) AS posts_read,
         SUM(uv.posts_read) /100 * :posts_read_score_value AS posts_read_score
       FROM user_visits uv
       WHERE uv.visited_at BETWEEN :start_date AND :end_date
         AND uv.posts_read >= 5
       GROUP BY uv.user_id
     ),

     posts_created AS (
       SELECT
         p.user_id,
         COUNT(*) AS posts_created,
         COUNT(*) * :posts_created_score_value AS posts_created_score
       FROM posts p
              INNER JOIN topics t ON t.id = p.topic_id
       WHERE p.deleted_at IS NULL
         AND t.archetype <> 'private_message'
         AND p.wiki IS FALSE
         AND p.created_at::date BETWEEN :start_date AND :end_date
       GROUP BY p.user_id
     ),

     topics_created AS (
       SELECT
         t.user_id,
         COUNT(*) AS topics_created,
         COUNT(*) * :topics_created_score_value AS topics_created_score
       FROM topics t
       WHERE t.deleted_at IS NULL
         AND t.archetype <> 'private_message'
         AND t.created_at::date BETWEEN :start_date AND :end_date
       GROUP BY t.user_id
     ),

     likes_received AS (
       SELECT
         p.user_id,
         COUNT(*) AS likes_received,
         COUNT(*) * :likes_received_score_value AS likes_received_score
       FROM post_actions pa
              INNER JOIN posts p ON p.id = pa.post_id
              INNER JOIN topics t ON t.id = p.topic_id
       WHERE p.deleted_at IS NULL
         AND t.archetype <> 'private_message'
         AND p.wiki IS FALSE
         AND post_action_type_id = 2
         AND pa.created_at::date BETWEEN :start_date AND :end_date
       GROUP BY p.user_id
     ),

     likes_given AS (
       SELECT
         pa.user_id AS user_id,
         COUNT(*) AS likes_given,
         COUNT(*) * :likes_given_score_value AS likes_given_score
       FROM post_actions pa
              INNER JOIN posts p ON p.id = pa.post_id
              INNER JOIN topics t ON t.id = p.topic_id
       WHERE p.deleted_at IS NULL
         AND t.archetype <> 'private_message'
         AND p.wiki IS FALSE
         AND post_action_type_id = 2
         AND pa.created_at::date BETWEEN :start_date AND :end_date
       GROUP BY pa.user_id
     ),

     solutions AS (
       SELECT
         p.user_id,
         COUNT(st.topic_id) AS solutions,
         COUNT(st.topic_id) * :solutions_score_value AS solutions_score
       FROM discourse_solved_solved_topics st
              INNER JOIN topics t ON st.topic_id = t.id
              INNER JOIN posts p ON p.id = st.answer_post_id
       WHERE p.deleted_at IS NULL
         AND t.deleted_at IS NULL
         AND t.archetype <> 'private_message'
         AND p.user_id <> t.user_id
         AND st.updated_at::date BETWEEN :start_date AND :end_date
       GROUP BY p.user_id
     ),

     flags AS (
       SELECT
         r.created_by_id AS user_id,
         COUNT(*) AS flags,
         COUNT(*) * :flag_created_score_value AS flags_score
       FROM reviewables r
       WHERE created_at::date BETWEEN :start_date AND :end_date
         AND status = 1
       GROUP BY user_id
     ),

     invites AS (
       SELECT
         inv.invited_by_id AS user_id,
         SUM(inv.redemption_count) AS invites,
         (SUM(inv.redemption_count) * :user_invited_score_value)::int AS invites_score
       FROM invites inv
       WHERE inv.created_at::date BETWEEN :start_date AND :end_date
         AND inv.redemption_count > 0
       GROUP BY inv.invited_by_id
     )

SELECT
  u.id AS user_id,
  u.username AS username,
  u.name AS name,
  (
    COALESCE(v.visits_score,0) +
    COALESCE(tr.time_read_score,0) +
    COALESCE(pr.posts_read_score,0) +
    COALESCE(pc.posts_created_score,0) +
    COALESCE(tc.topics_created_score,0) +
    COALESCE(lr.likes_received_score,0) +
    COALESCE(lg.likes_given_score,0) +
    COALESCE(s.solutions_score,0) +
    COALESCE(f.flags_score,0) +
    COALESCE(i.invites_score,0)
    ) AS "Total Cheers",
  COALESCE(v.user_visits,0) || ' (' || COALESCE(v.visits_score,0) || ')' AS "Visits (cheers)",
  COALESCE(tr.time_read,0) || 'hrs' || ' (' || COALESCE(tr.time_read_score,0) || ')' AS "Time Read (cheers)",
  COALESCE(pr.posts_read,0) || ' (' || COALESCE(pr.posts_read_score,0) || ')' AS "Posts Read (cheers)",
  COALESCE(pc.posts_created,0) || ' (' || COALESCE(pc.posts_created_score,0) || ')' AS "Posts Created (cheers)",
  COALESCE(tc.topics_created,0) || ' (' || COALESCE(tc.topics_created_score,0) || ')'AS "Topics Created (cheers)",
  COALESCE(lr.likes_received,0) || ' (' || COALESCE(lr.likes_received_score,0) || ')' AS "Likes Received (cheers)",
  COALESCE(lg.likes_given,0) || ' (' || COALESCE(lg.likes_given_score,0) || ')' AS "Likes Given (cheers)",
  COALESCE(s.solutions,0) || ' (' || COALESCE(s.solutions_score,0) || ')'AS "Solutions (cheers)",
  COALESCE(f.flags,0) || ' (' || COALESCE(f.flags_score,0) || ')' AS "Agreed Flags (cheers)",
  COALESCE(i.invites,0) || ' (' || COALESCE(i.invites_score,0) || ')' AS "Invites Redeemed (cheers)"
FROM users u
       LEFT JOIN visits v ON v.user_id = u.id
       LEFT JOIN posts_read pr USING (user_id)
       LEFT JOIN time_read tr USING (user_id)
       LEFT JOIN flags f USING (user_id)
       LEFT JOIN posts_created pc USING (user_id)
       LEFT JOIN topics_created tc USING (user_id)
       LEFT JOIN likes_given lg USING (user_id)
       LEFT JOIN likes_received lr USING (user_id)
       LEFT JOIN solutions s USING (user_id)
       LEFT JOIN invites i USING (user_id)
WHERE u.id > 0
  AND u.id NOT IN (SELECT user_id FROM group_users WHERE group_id = 3)
ORDER BY user_id ASC
OFFSET :page * :limit
  LIMIT :limit

Probar la consulta con tus valores de puntos actuales y propuestos

Una vez que hayas importado la consulta SQL, debes probarla utilizando un marco de tiempo que refleje el período en el que no deseas que los puntos cambien. Para nosotros, esto significaba desde el inicio de nuestro foro (2020-01-01) hasta el día antes de que quisiéramos realizar la actualización de nuestros valores de puntos (2024-05-15). Actualiza los valores de puntuación para reflejar tus valores de puntos actuales y prueba la consulta para asegurarte de que los valores sean correctos.

A continuación, mantén el mismo marco de tiempo pero cambia los valores de los puntos a los que deseas que sean. Toma nota de la diferencia en los valores para los mismos usuarios. En este ejemplo, Neil tenía un total de 1104 puntos (también conocidos como “cheers”) utilizando los valores de puntos actuales, y sus puntos aumentaron a 1245 utilizando los nuevos valores de puntos. Necesitamos un método para calcular la diferencia entre los puntos anteriores y posteriores y ajustar los puntos del usuario para que no vean un aumento o disminución en sus puntos después de implementar los nuevos valores de puntos.

Utilizando la API de gamificación externa para ajustar los puntos

Usando a Neil como ejemplo, sus puntos actuales son 1104 y sus puntos después del cambio propuesto serán 1245. Para asegurarnos de que no vea un aumento o disminución en sus puntos, necesitamos calcular la diferencia entre estos dos valores y luego asignar la diferencia a su puntuación. La diferencia se calcula como valorActual - valorNuevo, lo que en el caso de Neil sería 1104 - 1245 = -141. Esto significa que a Neil se le deben asignar -141 puntos. Podemos utilizar la API de gamificación externa para asignar estos puntos a su cuenta de usuario para que se reflejen en su puntuación en el tablero de clasificación. La llamada a la API que necesitamos hacer es la siguiente:

curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <tu clave>' \
--header 'Api-Username: <tu nombre de usuario>' \
--header 'Content-Type: application/json' \
--data '{
    "user_id": "101",
    "date": "2024-05-15",
    "points": "-141",
    "description": "Ajuste de puntos de gamificación"
}'

Ejecutar esta llamada a la API ajustará la puntuación total de Neil para que los nuevos valores de puntos solo afecten a los nuevos eventos puntuables, mientras que cualquier diferencia en los eventos puntuables antiguos se cancelará con el ajuste. Ahora solo necesitamos aplicar este proceso a cada usuario de la comunidad.

Automatizando los ajustes de puntos

Para automatizar este proceso, he creado un script en Python que aprovecha las APIs de Discourse para ejecutar las consultas SQL, calcular la diferencia y asignar los ajustes a cada usuario. El script también genera un archivo CSV con un registro de todos los usuarios que recibieron un ajuste. Los comentarios en el script hacen un buen trabajo describiendo los valores que necesitas cambiar y cómo funciona. Si tienes alguna pregunta sobre cómo hacer que esto funcione, por favor deja un comentario a continuación.

Requisitos

Necesitarás una versión reciente de Python 3. Yo utilizo Python 3.9.6. También necesitarás instalar el paquete requests desde PyPi.

python3 -m pip install requests

Script

import requests
import secrets
from datetime import date
from datetime import timedelta
import time
import csv
import json

# Las credenciales de la API se almacenan en un archivo secreto. Cómo suministrar los secretos depende de ti.
api_key = secrets.api_key
api_username = secrets.api_username

host = 'https://developer.sailpoint.com/discuss' # El nombre de host de tu instancia de Discourse

# Un enlace a un tema en tu foro que describa los cambios actuales y propuestos en los puntos. Es bueno tenerlo
# para que puedas consultarlo más tarde y entender qué cambió, así como hacer los cambios transparentes para tus usuarios.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'

# El nombre que deseas darle a tu archivo CSV. La fecha de inicio y la fecha de fin se añadirán cuando se cree.
csv_name = 'sailpoint_developer_community_point_adjustment' 

max_requests_per_minute = 200 # Máximo de solicitudes por período de 1 minuto. Ajusta esto para estar en o por debajo del límite de velocidad de la API de Discourse.
start_date = '2020-01-01' # El foro comenzó después de esta fecha, por lo que esto asegurará que se consideren todos los puntos.

# Esta fecha no puede ser hoy, o la consulta dará error. Esta fórmula utilizará la fecha de ayer. Puedes modificar esto
# para que sea más atrás en el pasado si lo deseas.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d') 

# El ID de tu consulta SQL. Puedes encontrarlo haciendo clic en tu consulta en el plugin de Explorador de Datos y luego buscando
# el parámetro "id" en la URL. Por ejemplo, mi ID es 66 como se muestra en esta URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'

# Estos son los valores de puntos actuales para tu configuración de puntuación de Gamificación. Estos cuatro eran aplicables a nuestro foro
# pero puedes agregar los otros tipos de puntos puntuables si es necesario.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'

# Estos son los valores de puntos propuestos que se aplicarán a todos los nuevos puntos.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'

# Esta función ejecuta la consulta SQL para obtener los valores de puntos para todos los usuarios. Paginará automáticamente hasta que no haya más registros.
def getPointValues(query_id, start_date, end_date, limit, likes_received_score_value, solutions_score_value, posts_created_score_value, flag_created_score_value):
    rows = []
    page = 0

    headers = {
        'Api-Key': api_key,
        'Api-Username': api_username,
        'Content-Type': 'application/x-www-form-urlencoded'
    }
        
    payload = f'params={{"start_date":"{start_date}","end_date":"{end_date}","limit":"{str(limit)}","page":"{str(page)}","likes_received_score_value":"{likes_received_score_value}","solutions_score_value":"{solutions_score_value}","posts_created_score_value":"{posts_created_score_value}","flag_created_score_value":"{flag_created_score_value}"}}'
    r = requests.post(f'{host}/admin/plugins/explorer/queries/{query_id}/run', headers=headers, data=payload).json()
    rows += r['rows']

    # Paginar hasta llegar a la última página
    while len(r['rows']) == limit:
        page += 1
        payload = f'params={{"start_date":"{start_date}","end_date":"{end_date}","limit":"{str(limit)}","page":"{str(page)}","likes_received_score_value":"{likes_received_score_value}","solutions_score_value":"{solutions_score_value}","posts_created_score_value":"{posts_created_score_value}","flag_created_score_value":"{flag_created_score_value}"}}'
        r = requests.post(f'{host}/admin/plugins/explorer/queries/{query_id}/run', headers=headers, data=payload).json()
        rows += r['rows']

    return rows

# Crear el array de ajustes de puntos que deben realizarse. Si un usuario tiene 0 puntos, o no hay diferencia en los puntos, se
# filtrarán para que no hagamos llamadas innecesarias a la API. Este array incluirá toda la información necesaria para realizar
# la llamada a la API de gamificación externa, así como para poblar el archivo CSV.
def calculatePointAdjustments(old_values, new_values):
    point_adjustments = []

    for row in old_values:
        user_id = row[0]
        username = row[1]
        name = row[2]
        current_cheers = row[3]
        for i in range(len(new_values)):
            new_cheers = new_values[i][3]
            if new_values[i][0] == user_id:
                # Solo calcular la diferencia si hay una diferencia en los puntos.
                if current_cheers != new_cheers:
                    point_adjustments.append({
                        'user_id': user_id,
                        'username': username,
                        'name': name,
                        'current_points': current_cheers,
                        'new_points': new_cheers,
                        'difference': current_cheers - new_cheers,
                        'external_gamification_point_id': -1
                    })
                    new_values.pop(i) # Eliminar el elemento de la lista para acelerar el procesamiento
                    break
                else:
                    new_values.pop(i) # Eliminar el elemento de la lista para acelerar el procesamiento
                    break
    
    return point_adjustments

# Guardar el array de ajustes de puntos en un archivo CSV para tu registro
def exportCSV(differences):
    fields = ['User ID', 'Username', 'Name', 'Current Points', 'New Points', 'Difference', 'External Gamification Point ID']
    rows = []
    for diff in differences:
        rows.append([diff['user_id'], diff['username'], diff['name'], diff['current_points'], diff['new_points'], diff['difference'], diff['external_gamification_point_id']])

    with open(f'{csv_name}_{start_date}_to_{end_date}.csv', 'w') as f:
        csv_writer = csv.writer(f)
        csv_writer.writerow(fields)
        csv_writer.writerows(rows)

# Ejecutar la API de gamificación para asignar la diferencia en los puntos para cada usuario. Esta función se ralentizará intencionalmente
# para mantenerse dentro de tu límite de velocidad. En caso de que se alcance un límite de velocidad, esta función continuará esperando hasta
# que el límite de velocidad expire y pueda continuar. Si en algún momento hay un error en la llamada a la API, los ajustes de puntos
# completados con éxito se guardarán en un archivo CSV para que sepas cuáles están completos y dónde debes reanudar.
def assignPointAdjustments(point_adjustments):
    assigned_point_adjustments = []
    # Auto-limitación de velocidad para evitar afectar otras integraciones
    sleep_time = 60 / max_requests_per_minute # Número de segundos a esperar entre cada llamada para mantenerse dentro del límite máximo de solicitudes
    endpoint = f'{host}/admin/plugins/gamification/score_events'
    headers = {
        'Api-Key': api_key,
        'Api-Username': api_username,
        'Content-Type': 'application/json'
    }

    for adjustment in point_adjustments:
        payload = json.dumps({
            "user_id": str(adjustment['user_id']),
            "date": end_date,
            "points": str(adjustment['difference']),
            "description": f'Ajustando puntos basados en nuevos valores de puntos de gamificación. Por favor, consulta {point_adjustment_link}.'
        })

        start = time.time() # Mantener un registro del tiempo de ejecución. Dado que las llamadas a la API tardan varios milisegundos, no contar este tiempo hacia el tiempo de espera máximo de solicitud.

        try:
            r = requests.post(endpoint, headers=headers, data=payload)
        except Exception as e:
            # En caso de fallo, guardar los ajustes asignados en un archivo CSV para saber cuáles ya están completos
            exportCSV(assigned_point_adjustments)
            raise

        # Si el máximo de solicitudes es inferior a 300, no deberíamos alcanzar un límite de velocidad. Si llegamos a alcanzarlo, manejarlo apropiadamente.
        while r.status_code == 429:
            wait_time = r.json()["extras"]["wait_seconds"] + 1
            print(f'Alcanzado el límite de velocidad. Durmiendo durante {wait_time} segundos')
            time.sleep(wait_time)
            try:
                r = requests.post(endpoint, headers=headers, data=payload)
                adjustment['external_gamification_point_id'] = r.json()["id"]
            except Exception as e:
                # En caso de fallo, guardar los ajustes asignados en un archivo CSV para saber cuáles ya están completos
                exportCSV(assigned_point_adjustments)
                raise
        
        if r.status_code != 200:
            exportCSV(assigned_point_adjustments)
            print(f'La última solicitud devolvió un error {r.status_code} con el siguiente mensaje de error\n{r.text}')
            print('Cancelando ajustes y escribiendo los ajustes exitosos en el archivo')
            raise Exception("HTTP Error")

        end = time.time()
        if end - start < sleep_time:
            # Solo dormir si el tiempo de solicitud fue menor que el tiempo de espera máximo de solicitud
            time.sleep(sleep_time - (end - start))
        
        adjustment['external_gamification_point_id'] = r.json()["id"]
        print(f'Asignado {adjustment["username"]} {adjustment["difference"]} puntos')
        assigned_point_adjustments.append(adjustment)

    return assigned_point_adjustments

limit = 1000 # Este es el límite máximo para consultas SQL.

current_values = getPointValues(query_id, start_date, end_date, limit, current_likes_received_score_value, current_solutions_score_value, current_posts_created_score_value, current_flag_created_score_value)
new_values = getPointValues(query_id, start_date, end_date, limit, new_likes_received_score_value, new_solutions_score_value, new_posts_created_score_value, new_flag_created_score_value)
point_adjustments = calculatePointAdjustments(current_values, new_values)
assigned_point_adjustments = assignPointAdjustments(point_adjustments)
exportCSV(assigned_point_adjustments)

Los pasos para ejecutar el ajuste de puntos

Ahora que tenemos todas las piezas en su lugar, deberás seguir estos pasos para ejecutar el proceso de ajuste de puntos. Se recomienda que pruebes este proceso en una instancia de prueba de tu entorno de Discourse primero para resolver cualquier problema.

  1. Actualiza las variables en el script de Python con los valores deseados.
  2. Cambia los valores de los puntos en la configuración de Gamificación a los nuevos valores.
  3. Recalcula las puntuaciones para “Todo el tiempo”.
  4. Ejecuta el script de Python y espera a que se complete. Dependiendo de cuántos usuarios haya en tu sistema y cuál sea tu límite de velocidad, esto puede tardar varios minutos en completarse. Tuvimos 2,200 usuarios que necesitaban un ajuste. Con un límite de velocidad de 200 solicitudes/minuto, tardó unos 15 minutos en completarse.
  5. Una vez completado, recalcula las puntuaciones para “Todo el tiempo” nuevamente, solo por seguridad.
  6. Guarda el archivo CSV en un lugar seguro para tus registros, por si necesitas revertir cualquier cambio.

El recálculo de puntos puede tardar unas horas en completarse. Necesitarás tener paciencia para ver que los ajustes surtan efecto. Una vez que el recálculo esté completo, ahora has ajustado con éxito los puntos de tus usuarios, y los puntos futuros se calcularán a partir de los nuevos valores de puntos.

Realizando más ajustes en el futuro

Si en algún momento necesitas realizar ajustes a los valores de los puntos, puedes seguir el mismo proceso. La única diferencia es que necesitas establecer la start_date en la end_date de cuando ejecutaste por última vez el ajuste de puntos. Por ejemplo, la primera vez que ejecuté este ajuste de puntos establecí la end_date en 2024-05-15. Esto aseguró que todos los puntos ganados entre el inicio de mi foro y 2024-05-15 se ajustaran para preservar los valores antiguos. Supongamos que pasa un año y quiero cambiar los valores de los puntos nuevamente. Ahora necesito establecer la start_date en 2024-05-15 y la end_date en 2025-05-15. Esto asegurará que el ajuste de puntos solo se aplique a ese período y no sobrescriba nada de la primera vez que ejecutaste el ajuste de puntos.

6 Me gusta