Come regolare i punti di gamification senza aggiornare retroattivamente i valori dei punti per le azioni precedenti

Enunciato del problema

Se utilizzi il plugin Discourse Gamification per assegnare punti agli utenti per i loro contributi alla tua community, potresti aver riscontrato la necessità di modificare i valori dei punti in base alle tendenze emergenti. Nella SailPoint Developer Community, utilizziamo il plugin Gamification per gestire il nostro programma Ambassador. Gli utenti che apportano contributi preziosi alla nostra community guadagnano punti, che a loro volta determinano il livello di benefit che ricevono. Man mano che la nostra community è cresciuta e il numero di contributi è aumentato drasticamente, abbiamo stabilito che i valori originali assegnati per determinati tipi di contributi necessitavano di una modifica. La modifica dei valori dei punti funziona bene se non si esegue mai una ricalcolazione dei punti, ma spesso dobbiamo ricalcolare i punti quando eseguiamo la fusione degli utenti o quando utilizziamo l’API di gamification esterna per assegnare punti per contributi passati. Se modifichi i valori dei punti della gamification ed esegui una ricalcolazione, i contributi precedenti valutati con i vecchi valori dei punti verranno ora valutati con i nuovi valori. Questo è un problema se desideri che i nuovi valori dei punti siano assegnati ai nuovi contributi, lasciando i vecchi contributi allo stesso valore. In questa guida, discuterò una soluzione che ho creato per garantire che i punti precedenti dei tuoi utenti non vengano alterati quando cambi i valori dei punti ed esegui una ricalcolazione.

Utilizzo di Data Explorer per calcolare i punti attuali e proposti

Importazione della query SQL

Il primo passo in questo processo è importare la seguente query SQL nel tuo plugin Data Explorer. Questa query SQL è stata modificata rispetto alla query SQL originale per includere la paginazione, aggiungere colonne aggiuntive per user_name e name, e ordinare per user_id. Questa query produrrà una tabella dei punteggi degli utenti per un determinato intervallo di tempo e utilizzando i valori di punteggio forniti.

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

Test della query utilizzando i valori di punti attuali e proposti

Una volta importata la query SQL, dovresti testarla utilizzando un intervallo di tempo che riflette il periodo in cui non desideri che i punti vengano modificati. Per noi, questo significava dall’inizio del nostro forum (2020-01-01) al giorno prima di voler apportare l’aggiornamento ai nostri valori dei punti (2024-05-15). Aggiorna i valori di punteggio per riflettere i tuoi valori di punti attuali e testa la query per assicurarti che i valori siano corretti.

Successivamente, mantieni lo stesso intervallo di tempo ma modifica i valori dei punti in quelli che desideri. Prendi nota della differenza nei valori per gli stessi utenti. In questo esempio, Neil aveva un totale di 1104 punti (aka cheers) utilizzando i valori di punti attuali, e i suoi punti sono aumentati a 1245 utilizzando i nuovi valori di punti. Abbiamo bisogno di un metodo per calcolare la differenza tra i punti prima e dopo e regolare i punti dell’utente in modo che non vedano un aumento o una diminuzione dei punti dopo l’implementazione dei nuovi valori di punti.

Utilizzo dell’API di gamification esterna per regolare i punti

Utilizzando Neil come esempio, i suoi punti attuali sono 1104 e i suoi punti dopo la modifica proposta saranno 1245. Per assicurarsi che non veda un aumento o una diminuzione dei punti, dobbiamo calcolare la differenza tra questi due valori e quindi assegnare la differenza al suo punteggio. La differenza è calcolata come valoreCorrente - valoreNuovo, che nel caso di Neil sarebbe 1104 - 1245 = -141. Questo significa che a Neil devono essere assegnati -141 punti. Possiamo utilizzare l’API di gamification esterna per assegnare questi punti al suo account utente in modo che si riflettano nel suo punteggio della classifica. La chiamata API che dobbiamo effettuare è la seguente:

curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <tua chiavi>' \
--header 'Api-Username: <il tuo nome utente>' \
--header 'Content-Type: application/json' \
--data '{
    "user_id": "101",
    "date": "2024-05-15",
    "points": "-141",
    "description": "Regolazione punti gamification"
}'

L’esecuzione di questa chiamata API regolerà il punteggio totale di Neil in modo che i nuovi valori di punti proposti influenzino solo i nuovi eventi punteggiabili, mentre qualsiasi differenza nei vecchi eventi punteggiabili verrà annullata dalla regolazione. Ora dobbiamo solo applicare questo processo a ogni utente della community.

Automazione delle regolazioni dei punti

Per automatizzare questo processo, ho creato uno script Python che sfrutta le API di Discourse per eseguire le query SQL, calcolare la differenza e assegnare le regolazioni a ogni utente. Lo script genera anche un file CSV con un registro di tutti gli utenti che hanno ricevuto una regolazione. I commenti nello script fanno un buon lavoro nel descrivere i valori che devi modificare e come funziona. Se hai domande su come far funzionare tutto questo, lascia un commento qui sotto.

Requisiti

Avrai bisogno di una versione recente di Python 3. Io uso Python 3.9.6. Dovrai anche installare il pacchetto requests da 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

# Le credenziali API sono memorizzate in un file segreto. Come fornire i segreti è a tua discrezione.
api_key = secrets.api_key
api_username = secrets.api_username

host = 'https://developer.sailpoint.com/discuss' # Il nome host della tua istanza Discourse

# Un link a un argomento nel tuo forum che descrive le modifiche attuali e proposte dei punti. È una buona cosa avere
# questo in modo da poterlo consultare in futuro per capire cosa è cambiato e rendere le modifiche trasparenti ai tuoi utenti.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'

# Il nome che desideri dare al tuo file CSV. La data di inizio e fine verranno aggiunte quando viene creato.
csv_name = 'sailpoint_developer_community_point_adjustment' 

max_requests_per_minute = 200 # Numero massimo di richieste per periodo di 1 minuto. Regola questo valore per essere pari o inferiore al limite di velocità della tua API Discourse.
start_date = '2020-01-01' # Il forum è iniziato dopo questa data, quindi questo garantirà che tutti i punti vengano considerati.

# Questa data non può essere oggi, altrimenti la query genererà un errore. Questa formula userà la data di ieri. Puoi modificarla
# per essere più indietro nel tempo se lo desideri.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d') 

# L'ID della tua query SQL. Puoi trovarlo cliccando sulla tua query nel plugin Data Explorer e cercando
# il parametro "id" nell'URL. Ad esempio, il mio ID è 66 come mostrato in questo URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'

# Questi sono i valori di punti attuali per le tue impostazioni di punteggio Gamification. Questi quattro erano applicabili al nostro forum
# ma puoi aggiungere altri tipi di punti punteggiabili se necessario.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'

# Questi sono i valori di punti proposti che verranno applicati a tutti i nuovi punti.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'

# Questa funzione esegue la query SQL per ottenere i valori dei punti per tutti gli utenti. Paginerà automaticamente fino a quando non ci saranno più record.
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']

    # Pagina fino a raggiungere l'ultima pagina
    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

# Crea l'array di regolazioni dei punti che devono essere effettuate. Se un utente ha 0 punti, o non c'è differenza nei punti, verranno
# filtrati in modo da non effettuare chiamate API non necessarie. Questo array includerà tutte le informazioni necessarie per effettuare
# la chiamata API di gamification esterna nonché per popolare il file 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:
                # Calcola la diff solo se c'è una differenza nei punti.
                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) # Rimuovi l'elemento dalla lista per accelerare l'elaborazione
                    break
                else:
                    new_values.pop(i) # Rimuovi l'elemento dalla lista per accelerare l'elaborazione
                    break
    
    return point_adjustments

# Salva l'array di regolazioni dei punti in un file CSV per la tua archiviazione
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)

# Esegui l'API di gamification per assegnare la differenza nei punti per ogni utente. Questa funzione rallenterà intenzionalmente
# per rimanere entro il limite di velocità. Nel caso in cui venga raggiunto il limite di velocità, questa funzione continuerà ad attendere fino a quando
# il limite di velocità non scadrà e potrà continuare. Se in qualsiasi momento si verifica un errore nella chiamata API, le regolazioni dei punti completate con successo
# verranno salvate in un file CSV in modo da sapere quali sono complete e dove riprendere.
def assignPointAdjustments(point_adjustments):
    assigned_point_adjustments = []
    # Auto-limitazione della velocità per non influenzare altre integrazioni
    sleep_time = 60 / max_requests_per_minute # Numero di secondi da attendere tra ogni chiamata per rimanere entro il limite massimo di richieste
    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'Aggiustamento punti basato sui nuovi valori di punti gamification. Si prega di consultare {point_adjustment_link}.'
        })

        start = time.time() # Tieni traccia del tempo di esecuzione. Poiché le chiamate API richiedono diversi millisecondi, non contare questo tempo nel tempo di attesa massimo per le richieste.

        try:
            r = requests.post(endpoint, headers=headers, data=payload)
        except Exception as e:
            # In caso di fallimento, salva le regolazioni assegnate in un file CSV in modo da sapere quali sono già complete
            exportCSV(assigned_point_adjustments)
            raise

        # Se il limite massimo di richieste è inferiore a 300, non dovremmo raggiungere un limite di velocità. Se per caso lo raggiungiamo, gestiscilo in modo appropriato.
        while r.status_code == 429:
            wait_time = r.json()["extras"]["wait_seconds"] + 1
            print(f'Limite di velocità raggiunto. In attesa di {wait_time} secondi')
            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:
                # In caso di fallimento, salva le regolazioni assegnate in un file CSV in modo da sapere quali sono già complete
                exportCSV(assigned_point_adjustments)
                raise
        
        if r.status_code != 200:
            exportCSV(assigned_point_adjustments)
            print(f'L'ultima richiesta ha restituito un errore {r.status_code} con il seguente messaggio di errore\n{r.text}')
            print('Interrompi gli aggiustamenti e scrivi gli aggiustamenti riusciti nel file')
            raise Exception("HTTP Error")

        end = time.time()
        if end - start < sleep_time:
            # Dormi solo se il tempo della richiesta è stato inferiore al tempo di attesa massimo per le richieste
            time.sleep(sleep_time - (end - start))
        
        adjustment['external_gamification_point_id'] = r.json()["id"]
        print(f'Assegnati {adjustment["difference"]} punti a {adjustment["username"]}')
        assigned_point_adjustments.append(adjustment)

    return assigned_point_adjustments

limit = 1000 # Questo è il limite massimo per le query 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)

I passaggi per eseguire la regolazione dei punti

Ora che abbiamo tutti i pezzi in posizione, dovrai seguire questi passaggi per eseguire il processo di regolazione dei punti. Si consiglia di testare questo processo in un’istanza di test del tuo ambiente Discourse prima per risolvere eventuali problemi.

  1. Aggiorna le variabili nello script Python con i tuoi valori desiderati.
  2. Modifica i valori dei punti nelle impostazioni di Gamification con i nuovi valori.
  3. Ricalcola i punteggi per “Tutto il tempo”.
  4. Esegui lo script Python e attendi che si completi. A seconda di quanti utenti ci sono nel tuo sistema e qual è il tuo limite di velocità, questo può richiedere diversi minuti per completarsi. Avevamo 2.200 utenti che necessitavano di una regolazione. Con un limite di velocità di 200 richieste/minuto, ci sono voluti circa 15 minuti per completarsi.
  5. Una volta completato, ricalcola nuovamente i punteggi per “Tutto il tempo”, solo per sicurezza.
  6. Salva il file CSV in un luogo sicuro per i tuoi registri, nel caso tu debba annullare eventuali modifiche.

Il ricalcolo dei punti può richiedere alcune ore per completarsi. Dovrai essere paziente per vedere le regolazioni avere effetto. Una volta completato il ricalcolo, hai ora regolato con successo i punti dei tuoi utenti, e i punti futuri verranno calcolati dai nuovi valori di punti.

Effettuare ulteriori regolazioni in futuro

Se in qualsiasi momento hai bisogno di apportare regolazioni ai valori dei punti, puoi seguire lo stesso processo. L’unica differenza è che devi impostare la start_date sulla end_date dell’ultima volta che hai eseguito la regolazione dei punti. Ad esempio, la prima volta che ho eseguito questa regolazione dei punti ho impostato la end_date su 2024-05-15. Questo ha assicurato che tutti i punti guadagnati tra l’inizio del mio forum e il 2024-05-15 fossero regolati per preservare i vecchi valori. Supponiamo che passi un anno e io voglia cambiare di nuovo i valori dei punti. Ora devo impostare la start_date su 2024-05-15 e la end_date su 2025-05-15. Questo garantirà che la regolazione dei punti si applichi solo a quel periodo e non sovrascriva nulla dalla prima volta che hai eseguito la regolazione dei punti.

6 Mi Piace