Como ajustar os pontos de gamificação sem atualizar retroativamente os valores dos pontos para ações antigas que podem ser pontuadas

Declaração do problema

Se você usa o Plugin de Gamificação do Discourse para conceder pontos aos usuários por contribuições à sua comunidade, pode ter percebido a necessidade de ajustar os valores dos pontos com base em tendências emergentes. Na Comunidade de Desenvolvedores da SailPoint, utilizamos o Plugin de Gamificação para impulsionar nosso Programa de Embaixadores. Usuários que fazem contribuições valiosas à nossa comunidade ganham pontos, que por sua vez são usados para determinar o nível de benefícios que recebem. À medida que nossa comunidade cresceu e o número de contribuições aumentou drasticamente, determinamos que os valores originais atribuídos a certos tipos de contribuições precisavam ser ajustados.

Ajustar os valores dos pontos funciona bem se você nunca fizer uma recálculo dos seus pontos, mas frequentemente precisamos recalcular os pontos ao realizar mesclagens de usuários ou ao usar a API de gamificação externa para conceder pontos por contribuições passadas. Se você modificar os valores dos pontos de gamificação e fizer um recálculo, as contribuições anteriores que foram pontuadas com os valores antigos agora serão pontuadas com os novos valores. Isso é um problema se você deseja que os novos valores de pontos sejam atribuídos apenas a novas contribuições, mantendo as contribuições antigas com o mesmo valor. Neste guia prático, discutirei uma solução que criei para garantir que os pontos anteriores dos seus usuários não sejam alterados quando você mudar os valores dos pontos e executar um recálculo.

Usando o Data Explorer para calcular pontos atuais e propostos

Importar a consulta SQL

A primeira etapa desse processo é importar a seguinte consulta SQL para o seu plugin Data Explorer. Esta consulta SQL foi modificada a partir da consulta SQL original para incluir paginação, adicionar colunas adicionais para user_name e name, e ordenar por user_id. Esta consulta produzirá uma tabela de pontuações dos usuários para um determinado intervalo de tempo e usando os valores de pontuação fornecidos.

-- [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

Testar a consulta usando seus valores de pontos atuais e propostos

Depois de importar a consulta SQL, você deve testá-la usando um período que reflita o intervalo no qual você não deseja que os pontos sejam alterados. Para nós, isso significava desde o início do nosso fórum (2020-01-01) até o dia anterior ao que desejávamos fazer a atualização dos nossos valores de pontos (2024-05-15). Atualize os valores de pontuação para refletir seus valores de pontos atuais e teste a consulta para garantir que os valores pareçam corretos.

Em seguida, mantenha o mesmo período, mas altere os valores dos pontos para o que você deseja que sejam. Observe a diferença nos valores para os mesmos usuários. Neste exemplo, Neil tinha um total de 1104 pontos (também conhecidos como cheers) usando os valores de pontos atuais, e seus pontos aumentaram para 1245 usando os novos valores de pontos. Precisamos de um método para calcular a diferença nos pontos antes e depois e ajustar os pontos do usuário para que ele não veja um aumento ou diminuição nos pontos após a implementação dos novos valores de pontos.

Usando a API de gamificação externa para ajustar os pontos

Usando Neil como exemplo, seus pontos atuais são 1104 e seus pontos após a alteração proposta serão 1245. Para garantir que ele não veja um aumento ou diminuição nos pontos, precisamos calcular a diferença entre esses dois valores e, em seguida, atribuir essa diferença à pontuação dele. A diferença é calculada como valorAtual - valorNovo, que no caso de Neil seria 1104 - 1245 = -141. Isso significa que Neil precisa receber -141 pontos. Podemos usar a API de gamificação externa para atribuir esses pontos à conta de usuário dele, de modo que eles sejam refletidos na pontuação do ranking dele. A chamada de API que precisamos fazer é a seguinte:

curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <sua_chave>' \
--header 'Api-Username: <seu_usuario>' \
--header 'Content-Type: application/json' \
--data '{
    "user_id": "101",
    "date": "2024-05-15",
    "points": "-141",
    "description": "Ajuste de pontos de gamificação"
}'

Executar esta chamada de API ajustará a pontuação total de Neil, de modo que os valores de pontos propostos afetarão apenas novos eventos pontuáveis, enquanto qualquer diferença nos eventos pontuáveis antigos será cancelada pelo ajuste. Agora, precisamos apenas aplicar esse processo a todos os usuários da comunidade.

Automatizando os ajustes de pontos

Para automatizar esse processo, criei um script Python que aproveita as APIs do Discourse para executar as consultas SQL, calcular a diferença e atribuir os ajustes a cada usuário. O script também gera um arquivo CSV com um registro de todos os usuários que receberam um ajuste. Os comentários no script fazem um bom trabalho descrevendo os valores que você precisa alterar e como ele funciona. Se houver alguma dúvida sobre como fazer isso funcionar, deixe um comentário abaixo.

Requisitos

Você precisará de uma versão recente do Python 3. Eu uso o Python 3.9.6. Você também precisará instalar o pacote requests do 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

# As credenciais da API são armazenadas em um arquivo secreto. Como você fornecerá os segredos fica a seu critério.
api_key = secrets.api_key
api_username = secrets.api_username

host = 'https://developer.sailpoint.com/discuss' # O nome de host da sua instância do Discourse

# Um link para um tópico no seu fórum descrevendo as alterações atuais e propostas nos pontos. Isso é algo bom de ter
# para que você possa olhar para trás e entender o que mudou, além de tornar as alterações transparentes para seus usuários.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'

# O nome que você deseja dar ao seu arquivo CSV. A data de início e a data de fim serão adicionadas quando for criado.
csv_name = 'sailpoint_developer_community_point_adjustment' 

max_requests_per_minute = 200 # Máximo de solicitações por período de 1 minuto. Ajuste isso para estar no limite ou abaixo do limite de taxa da API do Discourse.
start_date = '2020-01-01' # O fórum foi iniciado após esta data, então isso garantirá que todos os pontos sejam considerados.

# Esta data não pode ser hoje, ou a consulta dará erro. Esta fórmula usará a data de ontem. Você pode modificá-la
# para ser mais antiga no passado, se desejar.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d') 

# O ID da sua consulta SQL. Você pode encontrá-lo clicando na sua consulta no plugin Data Explorer e procurando
# o parâmetro "id" na URL. Por exemplo, meu ID é 66, conforme mostrado nesta URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'

# Estes são os valores de pontos atuais para suas configurações de pontuação de Gamificação. Estes quatro eram aplicáveis ao nosso fórum,
# mas você pode adicionar os outros tipos de pontos pontuáveis se precisar.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'

# Estes são os valores de pontos propostos que serão aplicados a todos os novos pontos.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'

# Esta função executa a consulta SQL para obter os valores de pontos para todos os usuários. Ela paginará automaticamente até que não haja mais 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 até chegarmos à ú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

# Crie o array de ajustes de pontos que precisam ser feitos. Se um usuário tiver 0 pontos, ou se não houver diferença nos pontos, eles
# serão filtrados para que não façamos chamadas de API desnecessárias. Este array incluirá todas as informações necessárias para fazer
# a chamada à API de gamificação externa, bem como preencher o arquivo 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:
                # Calcular a diferença apenas se houver uma diferença nos pontos.
                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) # Remover o elemento da lista para acelerar o processamento
                    break
                else:
                    new_values.pop(i) # Remover o elemento da lista para acelerar o processamento
                    break
    
    return point_adjustments

# Salvar o array de ajustes de pontos em um arquivo CSV para seus registros
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)

# Executar a API de gamificação para atribuir a diferença nos pontos para cada usuário. Esta função intencionalmente desacelerará
# para permanecer dentro do seu limite de taxa. Caso um limite de taxa seja atingido, esta função continuará aguardando até
# que o limite de taxa expire e ela possa continuar. Se a qualquer momento houver um erro na chamada da API, os ajustes de pontos
# concluídos com sucesso serão salvos em um arquivo CSV para que você saiba quais estão completos e onde precisa retomar.
def assignPointAdjustments(point_adjustments):
    assigned_point_adjustments = []
    # Auto-limitação de taxa para não afetar outras integrações
    sleep_time = 60 / max_requests_per_minute # Número de segundos para aguardar entre cada chamada para permanecer dentro do limite máximo de solicitações
    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 pontos com base nos novos valores de pontos de gamificação. Por favor, veja {point_adjustment_link}.'
        })

        start = time.time() # Acompanhar o tempo de execução. Como as chamadas de API levam vários milissegundos, não conte esse tempo para o tempo de espera máximo de solicitações.

        try:
            r = requests.post(endpoint, headers=headers, data=payload)
        except Exception as e:
            # Em caso de falha, salve os ajustes atribuídos em um arquivo CSV para sabermos quais já estão completos
            exportCSV(assigned_point_adjustments)
            raise

        # Se o máximo de solicitações for inferior a 300, não deveríamos atingir um limite de taxa. Se acaso atingirmos o limite de taxa, lidaremos com isso adequadamente.
        while r.status_code == 429:
            wait_time = r.json()["extras"]["wait_seconds"] + 1
            print(f'Atingiu o limite de taxa. Aguardando {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:
                # Em caso de falha, salve os ajustes atribuídos em um arquivo CSV para sabermos quais já estão completos
                exportCSV(assigned_point_adjustments)
                raise
        
        if r.status_code != 200:
            exportCSV(assigned_point_adjustments)
            print(f'A última solicitação retornou um erro {r.status_code} com a seguinte mensagem de erro\n{r.text}')
            print('Abortando ajustes e escrevendo os ajustes bem-sucedidos no arquivo')
            raise Exception("Erro HTTP")

        end = time.time()
        if end - start < sleep_time:
            # Aguardar apenas se o tempo da solicitação for menor que o tempo de espera máximo de solicitações
            time.sleep(sleep_time - (end - start))
        
        adjustment['external_gamification_point_id'] = r.json()["id"]
        print(f'Atribuído {adjustment["difference"]} pontos a {adjustment["username"]}')
        assigned_point_adjustments.append(adjustment)

    return assigned_point_adjustments

limit = 1000 # Este é o limite 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)

Os passos para executar o ajuste de pontos

Agora que temos todas as peças no lugar, você precisará seguir estes passos para executar o processo de ajuste de pontos. É recomendado que você teste este processo em uma instância de teste do seu ambiente Discourse primeiro, para que possa resolver quaisquer problemas.

  1. Atualize as variáveis no script Python para os valores desejados.
  2. Altere os valores dos pontos nas configurações de Gamificação para os novos valores.
  3. Recalcule as pontuações para “Todo o período”.
  4. Execute o script Python e aguarde sua conclusão. Dependendo de quantos usuários há no seu sistema e qual é o seu limite de taxa, isso pode levar vários minutos para ser concluído. Tivemos 2.200 usuários que precisavam de um ajuste. Com um limite de taxa de 200 solicitações/minuto, levou cerca de 15 minutos para ser concluído.
  5. Após a conclusão, recalcule as pontuações para “Todo o período” novamente, apenas para garantir.
  6. Salve o arquivo CSV em um local seguro para seus registros, caso precise reverter qualquer alteração.

O recálculo dos pontos pode levar algumas horas para ser concluído. Você precisará ter paciência para ver os ajustes surtirem efeito. Uma vez que o recálculo esteja completo, você terá ajustado com sucesso os pontos dos seus usuários, e os pontos a partir de agora serão calculados com base nos novos valores de pontos.

Fazendo mais ajustes no futuro

Se a qualquer momento você precisar fazer ajustes nos valores dos pontos, pode seguir o mesmo processo. A única diferença é que você precisa definir a start_date como a end_date da última vez que executou o ajuste de pontos. Por exemplo, a primeira vez que executei este ajuste de pontos, defini a end_date para 2024-05-15. Isso garantiu que todos os pontos ganhos entre o início do meu fórum e 2024-05-15 fossem ajustados para preservar os valores antigos. Vamos supor que um ano passe e eu queira mudar os valores dos pontos novamente. Agora preciso definir a start_date para 2024-05-15 e a end_date para 2025-05-15. Isso garantirá que o ajuste de pontos se aplique apenas a esse período e não substitua nada da primeira vez que você executou o ajuste de pontos.

6 curtidas