diff --git a/checkers-app/src/services/api.ts b/checkers-app/src/services/api.ts index 0f8d6da4..6ae8e34a 100644 --- a/checkers-app/src/services/api.ts +++ b/checkers-app/src/services/api.ts @@ -216,3 +216,15 @@ export const sendWhatsappTestMessage = async ( } as postWhatsappTestMessage) ).data; }; + +export const resetCheckerProgram = async (checkerId: string) => { + if (!checkerId) { + throw new Error("Checker ID missing."); + } + const checkerUpdateData: updateChecker = { + programData: "reset", + }; + return ( + await axiosInstance.patch(`/api/checkers/${checkerId}`, checkerUpdateData) + ).data; +}; diff --git a/functions/src/definitions/api/authentication.ts b/functions/src/definitions/api/authentication.ts index 0b413818..87df1037 100644 --- a/functions/src/definitions/api/authentication.ts +++ b/functions/src/definitions/api/authentication.ts @@ -4,9 +4,11 @@ import express from "express" import * as crypto from "crypto" import { onRequest } from "firebase-functions/v2/https" import { logger } from "firebase-functions" -import { Checker } from "../../types" +import { CheckerData } from "../../types" import { defineString } from "firebase-functions/params" import { AppEnv } from "../../appEnv" +import { getThresholds } from "../common/utils" +import { Timestamp } from "firebase-admin/firestore" if (!admin.apps.length) { admin.initializeApp() @@ -119,7 +121,9 @@ app.post("/", async (req, res) => { //from telegram but not yet a user in database functions.logger.info("Creating new user") - const checkerObject: Checker = { + const thresholds = await getThresholds() + + const checkerObject: CheckerData = { name: "", type: "human", isActive: false, @@ -133,7 +137,10 @@ app.post("/", async (req, res) => { experience: 0, tier: "beginner", numVoted: 0, + numReferred: 0, + numReported: 0, numCorrectVotes: 0, + numNonUnsureVotes: 0, numVerifiedLinks: 0, preferredPlatform: "telegram", lastVotedTimestamp: null, @@ -144,6 +151,20 @@ app.post("/", async (req, res) => { totalTimeTaken: 0, score: 0, }, + programData: { + isOnProgram: true, + programStart: Timestamp.fromDate(new Date()), + programEnd: null, + numVotesTarget: thresholds.volunteerProgramVotesRequirement ?? 0, //target number of messages voted on to complete program + numReferralTarget: thresholds.volunteerProgramReferralRequirement ?? 0, //target number of referrals made to complete program + numReportTarget: thresholds.volunteerProgramReportRequirement ?? 0, //number of non-trivial messages sent in to complete program + accuracyTarget: thresholds.volunteerProgramAccuracyRequirement ?? 0, //target accuracy of non-unsure votes + numVotesAtProgramStart: 0, + numReferralsAtProgramStart: 0, + numReportsAtProgramStart: 0, + numCorrectVotesAtProgramStart: 0, + numNonUnsureVotesAtProgramStart: 0, + }, } try { diff --git a/functions/src/definitions/api/handlers/getChecker.ts b/functions/src/definitions/api/handlers/getChecker.ts index 052acf5d..5acc49c1 100644 --- a/functions/src/definitions/api/handlers/getChecker.ts +++ b/functions/src/definitions/api/handlers/getChecker.ts @@ -1,10 +1,12 @@ import { Request, Response } from "express" import { Checker } from "../interfaces" +import { CheckerData } from "../../../types" import * as admin from "firebase-admin" -import { Timestamp } from "firebase-admin/firestore" import { logger } from "firebase-functions/v2" -import { checkAccuracy } from "../../common/statistics" -import { TIME } from "../../../utils/time" +import { + computeLast30DaysStats, + computeProgramStats, +} from "../../common/statistics" if (!admin.apps.length) { admin.initializeApp() } @@ -25,7 +27,7 @@ const getCheckerHandler = async (req: Request, res: Response) => { return res.status(404).send(`Checker with id ${checkerId} not found`) } - const checkerData = checkerSnap.data() + const checkerData = checkerSnap.data() as CheckerData if (!checkerData) { return res.status(500).send("Checker data not found") @@ -38,98 +40,6 @@ const getCheckerHandler = async (req: Request, res: Response) => { const pendingVoteSnap = await pendingVoteQuery.count().get() const pendingVoteCount = pendingVoteSnap.data().count - const cutoffTimestamp = Timestamp.fromDate( - new Date(Date.now() - TIME.THIRTY_DAYS) - ) - - const last30DaysQuery = db - .collectionGroup("voteRequests") - .where("factCheckerDocRef", "==", checkerRef) - .where("createdTimestamp", ">", cutoffTimestamp) - - const last30DaysSnap = await last30DaysQuery.get() - - //filter client side for category != null, since firestore doesn't support inequality on 2 fields - const last30DaysData = last30DaysSnap.docs.filter( - (doc) => doc.get("category") !== null && doc.get("category") !== "pass" - ) - - const totalVoted = last30DaysData.length - - // Map each document to a promise to fetch the parent message and count instances - const fetchDataPromises = last30DaysData.map((doc) => { - const parentMessageRef = doc.ref.parent.parent // Assuming this is how you get the reference - if (!parentMessageRef) { - logger.error(`Vote request ${doc.id} has no parent message`) - return null - } - - // You can fetch the parent message and count instances in parallel for each doc - return Promise.all([ - parentMessageRef.get(), - parentMessageRef.collection("instances").count().get(), - ]) - .then(([parentMessageSnap, instanceCountResult]) => { - if (!parentMessageSnap.exists) { - logger.error(`Parent message not found for vote request ${doc.id}`) - return null - } - const instanceCount = instanceCountResult.data().count ?? 0 - const isAccurate = checkAccuracy(parentMessageSnap, doc) - const isAssessed = parentMessageSnap.get("isAssessed") ?? false - const votedTimestamp = doc.get("votedTimestamp") ?? null - const createdTimestamp = doc.get("createdTimestamp") ?? null - - // You may adjust what you return based on your needs - return { - votedTimestamp, - createdTimestamp, - isAccurate, - isAssessed, - instanceCount, - } - }) - .catch((error) => { - logger.error( - `Error fetching data for vote request ${doc.id}: ${error}` - ) - return null // Handle errors as appropriate for your use case - }) - }) - - // Wait for all fetches to complete - const results = await Promise.all(fetchDataPromises) - //calculate accuracy - const accurateCount = results.filter( - (d) => d !== null && d.isAccurate - ).length - const totalAssessedAndNonUnsureCount = results.filter( - (d) => d !== null && d.isAssessed && d.isAccurate !== null - ).length - const totalCount = results.filter((d) => d !== null).length - //calculate people helped - const peopleHelped = results.reduce( - (acc, d) => acc + (d !== null ? d.instanceCount : 0), - 0 - ) - //calculate average response time, given data has a createdTimestamp and a votedTimestamp - const totalResponseTime = results.reduce((acc, d) => { - if (d === null) { - return acc - } - if (d.createdTimestamp && d.votedTimestamp) { - const responseTimeMinutes = - (d.votedTimestamp.toMillis() - d.createdTimestamp.toMillis()) / 60000 - return acc + responseTimeMinutes - } - return acc - }, 0) - const averageResponseTime = totalResponseTime / (totalCount || 1) - - const accuracyRate = - totalAssessedAndNonUnsureCount === 0 - ? null - : accurateCount / totalAssessedAndNonUnsureCount const returnData: Checker = { name: checkerData.name, type: checkerData.type, @@ -137,16 +47,50 @@ const getCheckerHandler = async (req: Request, res: Response) => { tier: checkerData.tier, isAdmin: checkerData.isAdmin, isOnboardingComplete: checkerData.isOnboardingComplete, + isOnProgram: checkerData.programData.isOnProgram ?? false, pendingVoteCount: pendingVoteCount, - last30days: { + achievements: null, + level: 0, //TODO,check + experience: 0, //TOD0 + } + + if (checkerData.programData.isOnProgram) { + try { + const { + numVotes, + numReferrals, + numReports, + accuracy, + isProgramCompleted, + } = await computeProgramStats(checkerSnap) + + returnData.programStats = { + //TODO + numVotes: numVotes, + numVotesTarget: checkerData.programData.numVotesTarget, + numReferrals: numReferrals, + numReferralTarget: checkerData.programData.numReferralTarget, + numReports: numReports, + numReportTarget: checkerData.programData.numReportTarget, + accuracy: accuracy, + accuracyTarget: checkerData.programData.accuracyTarget, + isProgramCompleted: isProgramCompleted, + } + } catch { + logger.error("Error fetching program stats") + return res.status(500).send("Error fetching program stats") + } + //calculate and send back program statistics + } else { + //calculate and send back last 30 day statistics + const { totalVoted, accuracyRate, averageResponseTime, peopleHelped } = + await computeLast30DaysStats(checkerSnap) + returnData.last30days = { totalVoted: totalVoted, accuracyRate: accuracyRate, averageResponseTime: averageResponseTime, peopleHelped: peopleHelped, - }, - achievements: null, - level: 0, //TODO, - experience: 0, //TOD0 + } } res.status(200).send(returnData) } catch (error) { diff --git a/functions/src/definitions/api/handlers/patchChecker.ts b/functions/src/definitions/api/handlers/patchChecker.ts index ecc71db5..ed1c10e3 100644 --- a/functions/src/definitions/api/handlers/patchChecker.ts +++ b/functions/src/definitions/api/handlers/patchChecker.ts @@ -1,7 +1,9 @@ import { Request, Response } from "express" -import { Checker } from "../../../types" +import { CheckerData } from "../../../types" import * as admin from "firebase-admin" import { logger } from "firebase-functions/v2" +import { Timestamp } from "firebase-admin/firestore" +import { getThresholds } from "../../common/utils" if (!admin.apps.length) { admin.initializeApp() } @@ -26,7 +28,7 @@ const patchCheckerHandler = async (req: Request, res: Response) => { //check keys in request body, make sure they are defined in checker type const body = req.body const keys = Object.keys(body) - const checker = checkerSnap.data() as Checker + const checker = checkerSnap.data() as CheckerData const checkerKeys = Object.keys(checker) const validKeys = keys.every((key) => checkerKeys.includes(key)) @@ -50,11 +52,39 @@ const patchCheckerHandler = async (req: Request, res: Response) => { return res.status(400).send("tier cannot be updated") } + if (keys.includes("programData")) { + if ( + typeof body.programData !== "string" || + body.programData !== "reset" + ) { + return res + .status(400) + .send("programData will only work with the value 'reset'") + } else { + const thresholds = await getThresholds() + body.programData = { + isOnProgram: true, + programStart: Timestamp.fromDate(new Date()), + programEnd: null, + numVotesTarget: thresholds.volunteerProgramVotesRequirement ?? 0, //target number of messages voted on to complete program + numReferralTarget: + thresholds.volunteerProgramReferralRequirement ?? 0, //target number of referrals made to complete program + numReportTarget: thresholds.volunteerProgramReportRequirement ?? 0, //number of non-trivial messages sent in to complete program + accuracyTarget: thresholds.volunteerProgramAccuracyRequirement ?? 0, //target accuracy of non-unsure votes + numVotesAtProgramStart: checker.numVoted ?? 0, + numReferralsAtProgramStart: checker.numReferred ?? 0, + numReportsAtProgramStart: checker.numReported ?? 0, + numCorrectVotesAtProgramStart: checker.numCorrectVotes ?? 0, + numNonUnsureVotesAtProgramStart: checker.numNonUnsureVotes ?? 0, + } + } + } + //update checker await checkerRef.update(body) const updatedCheckerSnap = await checkerRef.get() - const updatedChecker = updatedCheckerSnap.data() as Checker + const updatedChecker = updatedCheckerSnap.data() as CheckerData res.status(200).send(updatedChecker) } catch (error) { diff --git a/functions/src/definitions/api/handlers/postChecker.ts b/functions/src/definitions/api/handlers/postChecker.ts index dd81ee8d..7c0da847 100644 --- a/functions/src/definitions/api/handlers/postChecker.ts +++ b/functions/src/definitions/api/handlers/postChecker.ts @@ -1,8 +1,10 @@ import { Request, Response } from "express" import { createChecker } from "../interfaces" -import { Checker } from "../../../types" +import { CheckerData } from "../../../types" import * as admin from "firebase-admin" import { logger } from "firebase-functions/v2" +import { Timestamp } from "firebase-admin/firestore" +import { getThresholds } from "../../common/utils" if (!admin.apps.length) { admin.initializeApp() @@ -23,7 +25,10 @@ const postCheckerHandler = async (req: Request, res: Response) => { level, experience, numVoted, + numReferred, + numReported, numCorrectVotes, + numNonUnsureVotes, numVerifiedLinks, preferredPlatform, lastVotedTimestamp, @@ -44,7 +49,10 @@ const postCheckerHandler = async (req: Request, res: Response) => { return res.status(409).send("Checker agent name already exists") } } - const newChecker: Checker = { + + const thresholds = await getThresholds() + + const newChecker: CheckerData = { name, type, isActive: isActive || false, @@ -58,7 +66,10 @@ const postCheckerHandler = async (req: Request, res: Response) => { experience: experience || 0, tier: "beginner", numVoted: numVoted || 0, + numReferred: numReferred || 0, + numReported: numReported || 0, numCorrectVotes: numCorrectVotes || 0, + numNonUnsureVotes: numNonUnsureVotes || 0, numVerifiedLinks: numVerifiedLinks || 0, preferredPlatform: preferredPlatform || type === "ai" ? null : "telegram", lastVotedTimestamp: lastVotedTimestamp || null, @@ -69,6 +80,20 @@ const postCheckerHandler = async (req: Request, res: Response) => { totalTimeTaken: 0, score: 0, }, + programData: { + isOnProgram: type === "human" ? true : false, + programStart: type === "human" ? Timestamp.fromDate(new Date()) : null, + programEnd: null, + numVotesTarget: thresholds.volunteerProgramVotesRequirement ?? 0, //target number of messages voted on to complete program + numReferralTarget: thresholds.volunteerProgramReferralRequirement ?? 0, //target number of referrals made to complete program + numReportTarget: thresholds.volunteerProgramReportRequirement ?? 0, //number of non-trivial messages sent in to complete program + accuracyTarget: thresholds.volunteerProgramAccuracyRequirement ?? 0, //target accuracy of non-unsure votes + numVotesAtProgramStart: 0, + numReferralsAtProgramStart: 0, + numReportsAtProgramStart: 0, + numCorrectVotesAtProgramStart: 0, + numNonUnsureVotesAtProgramStart: 0, + }, } logger.info("Creating new checker", newChecker) diff --git a/functions/src/definitions/api/interfaces.ts b/functions/src/definitions/api/interfaces.ts index b92141c9..05b67f1f 100644 --- a/functions/src/definitions/api/interfaces.ts +++ b/functions/src/definitions/api/interfaces.ts @@ -1,4 +1,4 @@ -import { CustomReply, LeaderboardEntry } from "../../types" +import { CustomReply, LeaderboardEntry, ProgramData } from "../../types" interface createVoteRequest { factCheckerId?: string @@ -32,7 +32,10 @@ interface createChecker { level?: number experience?: number numVoted?: number + numReferred?: number + numReported?: number numCorrectVotes?: number + numNonUnsureVotes?: number numVerifiedLinks?: number preferredPlatform?: string | null lastVotedTimestamp?: null @@ -49,10 +52,14 @@ interface updateChecker { level?: number experience?: number numVoted?: number + numReferred?: number + numReported?: number numCorrectVotes?: number + numNonUnsureVotes?: number numVerifiedLinks?: number preferredPlatform?: string | null lastVotedTimestamp?: null + programData?: "reset" } interface Checker { @@ -62,13 +69,30 @@ interface Checker { isOnboardingComplete: boolean | null tier: "beginner" | "intermediate" | "expert" isAdmin: boolean + isOnProgram: boolean pendingVoteCount: number - last30days: last30DaysStats + last30days?: Last30DaysStats + programStats?: ProgramStats achievements: Achievements | null level: number experience: number } +interface ProgramStats + extends Pick< + ProgramData, + | "numVotesTarget" + | "numReferralTarget" + | "numReportTarget" + | "accuracyTarget" + > { + accuracy: number | null + numVotes: number + numReferrals: number + numReports: number + isProgramCompleted: boolean +} + interface VoteSummary { category: string | null truthScore: number | null @@ -111,7 +135,7 @@ interface Vote { finalStats: AssessedInfo | null } -interface last30DaysStats { +interface Last30DaysStats { totalVoted: number accuracyRate: number | null averageResponseTime: number diff --git a/functions/src/definitions/common/parameters/checkerResponses.json b/functions/src/definitions/common/parameters/checkerResponses.json index 4d7cf65e..d112677e 100644 --- a/functions/src/definitions/common/parameters/checkerResponses.json +++ b/functions/src/definitions/common/parameters/checkerResponses.json @@ -12,5 +12,6 @@ "ONBOARDING_4": "Awesome! Now that you know how to identify misinformation and scams, you are ready to help us combat them! 🙌🏻 If you haven't already, do join this WhatsApp group (https://bit.ly/checkmates-groupchat) that brings together all the other CheckMates and the core product team for updates and feedback. If you're looking for resources, you can visit our wiki page (https://bit.ly/checkmates-wiki). Thanks again for joining our community of CheckMates. Enjoy! 👋🏻🤖", "NOT_A_REPLY": "Sorry, did you forget to reply to a message? You need to swipe right on the message to reply to it.", "OUTSTANDING_REMINDER": "You have *{{num_outstanding}} remaining messages* to assess. Would you like to be sent the next one in line?", - "NO_OUTSTANDING": "Great, you have no further messages to assess. Keep it up!💪" + "NO_OUTSTANDING": "Great, you have no further messages to assess. Keep it up!💪", + "PROGRAM_COMPLETED": "Congratulations! You have completed our CheckMate volunteers program with the following stats:\n\nNo. of messages voted on: {{num_messages}}\nAccuracy: {{accuracy}}\nNo. of new users referred: {{num_referred}}\nNo. of non-trivial messages reported: {{num_reported}}\n\nand are now a certified CheckMate!! 🎉🥳" } diff --git a/functions/src/definitions/common/parameters/thresholds.json b/functions/src/definitions/common/parameters/thresholds.json index 3272fb23..459f7063 100644 --- a/functions/src/definitions/common/parameters/thresholds.json +++ b/functions/src/definitions/common/parameters/thresholds.json @@ -14,5 +14,9 @@ "misleadingUpperBound": 4, "sendInterimMinVotes": 1, "surveyLikelihood": 1, - "satisfactionSurveyCooldownDays": 30 + "satisfactionSurveyCooldownDays": 30, + "volunteerProgramVotesRequirement": 50, + "volunteerProgramReferralRequirement": 0, + "volunteerProgramReportRequirement": 10, + "volunteerProgramAccuracyRequirement": 0.6 } diff --git a/functions/src/definitions/common/sendTelegramMessage.ts b/functions/src/definitions/common/sendTelegramMessage.ts index db3a6674..91bccaa7 100644 --- a/functions/src/definitions/common/sendTelegramMessage.ts +++ b/functions/src/definitions/common/sendTelegramMessage.ts @@ -1,20 +1,30 @@ import axios from "axios" import * as functions from "firebase-functions" import FormData from "form-data" -import { Update, InlineKeyboardMarkup, ForceReply, Message } from "node-telegram-bot-api" +import { + Update, + InlineKeyboardMarkup, + ForceReply, + Message, +} from "node-telegram-bot-api" const telegramHost = process.env["TEST_SERVER_URL"] || "https://api.telegram.org" //only exists in integration test environment const sendTelegramTextMessage = async function ( bot: string, - to: string, + to: string | number, text: string, replyId: string | null = null, reply_markup: InlineKeyboardMarkup | null = null ) { let token - let data: { chat_id: string; text: string; reply_to_message_id?: string, reply_markup?: InlineKeyboardMarkup } + let data: { + chat_id: string | number + text: string + reply_to_message_id?: string + reply_markup?: InlineKeyboardMarkup + } if (bot == "factChecker") { token = process.env.TELEGRAM_CHECKER_BOT_TOKEN } else if (bot === "report") { diff --git a/functions/src/definitions/common/statistics.ts b/functions/src/definitions/common/statistics.ts index 243ccd22..e68d5c63 100644 --- a/functions/src/definitions/common/statistics.ts +++ b/functions/src/definitions/common/statistics.ts @@ -1,6 +1,11 @@ import * as admin from "firebase-admin" import { logger } from "firebase-functions/v2" import { LeaderboardEntry } from "../../types" +import { Timestamp } from "firebase-admin/firestore" +import { TIME } from "../../utils/time" +import { CheckerData } from "../../types" + +const db = admin.firestore() function checkAccuracy( parentMessageSnap: admin.firestore.DocumentSnapshot, @@ -79,7 +84,6 @@ function computeGamificationScore( } async function getFullLeaderboard(): Promise { - const db = admin.firestore() const leaderboardQuery = db .collection("checkers") .where("isActive", "==", true) @@ -127,10 +131,246 @@ function tabulateVoteStats( return { isCorrect, score, duration } } +// async function computeProgramStats( +// checkerSnap: admin.firestore.DocumentSnapshot +// ) { +// try { +// const checkerData = checkerSnap.data() as CheckerData +// const programStart = checkerData.programData?.programStart ?? null +// const isOnProgram = checkerData.programData?.isOnProgram ?? false + +// if (!isOnProgram) { +// throw new Error(`Checker ${checkerSnap.ref.id} is not on program`) +// } +// if (!programStart) { +// throw new Error( +// `Checker ${checkerSnap.ref.id} is on program but has no program start` +// ) +// } +// const programDurationQuery = db +// .collectionGroup("voteRequests") +// .where("factCheckerDocRef", "==", checkerSnap.ref) +// .where("createdTimestamp", ">", programStart) + +// const programDurationSnap = await programDurationQuery.get() +// const programDurationData = programDurationSnap.docs.filter( +// (doc) => doc.get("category") !== null && doc.get("category") !== "pass" +// ) + +// const numVotes = programDurationData.length + +// const fetchDataPromises = programDurationData.map((doc) => { +// const parentMessageRef = doc.ref.parent.parent +// if (!parentMessageRef) { +// logger.error(`Vote request ${doc.id} has no parent message`) +// return null +// } +// return parentMessageRef.get().then((parentMessageSnap) => { +// if (!parentMessageSnap.exists) { +// logger.error(`Parent message not found for vote request ${doc.id}`) +// return null +// } +// const isAccurate = checkAccuracy(parentMessageSnap, doc) +// const isAssessed = parentMessageSnap.get("isAssessed") ?? false +// return { +// isAccurate, +// isAssessed, +// } +// }) +// }) + +// const results = await Promise.all(fetchDataPromises) +// const accurateCount = results.filter( +// (d) => d !== null && d.isAssessed && d.isAccurate +// ).length +// const totalAssessedAndNonUnsureCount = results.filter( +// (d) => d !== null && d.isAssessed && d.isAccurate !== null +// ).length + +// const accuracyRate = +// totalAssessedAndNonUnsureCount === 0 +// ? null +// : accurateCount / totalAssessedAndNonUnsureCount +// return { +// numVotes, +// accuracyRate, +// } +// } catch (error) { +// logger.error( +// `Error computing program stats for checker ${checkerSnap.ref.id}: ${error}` +// ) +// throw error +// } +// } + +async function computeProgramStats( + checkerSnap: admin.firestore.DocumentSnapshot +) { + try { + const checkerData = checkerSnap.data() as CheckerData + if (!checkerData.programData?.isOnProgram) { + throw new Error(`Checker ${checkerSnap.ref.id} is not on program`) + } + const numVotes = + checkerData.numVoted - checkerData.programData.numVotesAtProgramStart + const numCorrectVotes = + checkerData.numCorrectVotes - + checkerData.programData.numCorrectVotesAtProgramStart + const numNonUnsureVotes = + checkerData.numNonUnsureVotes - + checkerData.programData.numNonUnsureVotesAtProgramStart + const numReferrals = + checkerData.numReferred - + checkerData.programData.numReferralsAtProgramStart + const numReports = + checkerData.numReported - checkerData.programData.numReportsAtProgramStart + const accuracy = + numNonUnsureVotes === 0 ? null : numCorrectVotes / numNonUnsureVotes + const numVotesTarget = checkerData.programData.numVotesTarget + const numReferralTarget = checkerData.programData.numReferralTarget + const numReportTarget = checkerData.programData.numReportTarget + const accuracyTarget = checkerData.programData.accuracyTarget + const isVotesTargetMet = numVotes >= numVotesTarget + const isReferralTargetMet = numReferrals >= numReferralTarget + const isReportTargetMet = numReports >= numReportTarget + const isAccuracyTargetMet = accuracy !== null && accuracy >= accuracyTarget + const isProgramCompleted = //program is completed if it has previously been deemed complete (in case votes change it again), or if all targets are met + checkerData.programData.programEnd != null || + (isVotesTargetMet && + isReferralTargetMet && + isReportTargetMet && + isAccuracyTargetMet) + let isNewlyCompleted = false + if (isProgramCompleted && checkerData.programData.programEnd == null) { + await checkerSnap.ref.update({ + "programData.programEnd": Timestamp.fromDate(new Date()), + }) + isNewlyCompleted = true + } + return { + numVotes, + numReferrals, + numReports, + accuracy, + isProgramCompleted, + isNewlyCompleted, + } + } catch (error) { + logger.error( + `Error computing program stats for checker ${checkerSnap.ref.id}: ${error}` + ) + throw error + } +} + +async function computeLast30DaysStats( + checkerSnap: admin.firestore.DocumentSnapshot +) { + const cutoffTimestamp = Timestamp.fromDate( + new Date(Date.now() - TIME.THIRTY_DAYS) + ) + + const last30DaysQuery = db + .collectionGroup("voteRequests") + .where("factCheckerDocRef", "==", checkerSnap.ref) + .where("createdTimestamp", ">", cutoffTimestamp) + + const last30DaysSnap = await last30DaysQuery.get() + + //filter client side for category != null, since firestore doesn't support inequality on 2 fields + const last30DaysData = last30DaysSnap.docs.filter( + (doc) => doc.get("category") !== null && doc.get("category") !== "pass" + ) + + const totalVoted = last30DaysData.length + + // Map each document to a promise to fetch the parent message and count instances + const fetchDataPromises = last30DaysData.map((doc) => { + const parentMessageRef = doc.ref.parent.parent // Assuming this is how you get the reference + if (!parentMessageRef) { + logger.error(`Vote request ${doc.id} has no parent message`) + return null + } + + // You can fetch the parent message and count instances in parallel for each doc + return Promise.all([ + parentMessageRef.get(), + parentMessageRef.collection("instances").count().get(), + ]) + .then(([parentMessageSnap, instanceCountResult]) => { + if (!parentMessageSnap.exists) { + logger.error(`Parent message not found for vote request ${doc.id}`) + return null + } + const instanceCount = instanceCountResult.data().count ?? 0 + const isAccurate = checkAccuracy(parentMessageSnap, doc) + const isAssessed = parentMessageSnap.get("isAssessed") ?? false + const votedTimestamp = doc.get("votedTimestamp") ?? null + const createdTimestamp = doc.get("createdTimestamp") ?? null + + // You may adjust what you return based on your needs + return { + votedTimestamp, + createdTimestamp, + isAccurate, + isAssessed, + instanceCount, + } + }) + .catch((error) => { + logger.error(`Error fetching data for vote request ${doc.id}: ${error}`) + return null // Handle errors as appropriate for your use case + }) + }) + + // Wait for all fetches to complete + const results = await Promise.all(fetchDataPromises) + //calculate accuracy + const accurateCount = results.filter( + (d) => d !== null && d.isAssessed && d.isAccurate + ).length + const totalAssessedAndNonUnsureCount = results.filter( + (d) => d !== null && d.isAssessed && d.isAccurate !== null + ).length + const totalCount = results.filter((d) => d !== null).length + //calculate people helped + const peopleHelped = results.reduce( + (acc, d) => acc + (d !== null ? d.instanceCount : 0), + 0 + ) + //calculate average response time, given data has a createdTimestamp and a votedTimestamp + const totalResponseTime = results.reduce((acc, d) => { + if (d === null) { + return acc + } + if (d.createdTimestamp && d.votedTimestamp) { + const responseTimeMinutes = + (d.votedTimestamp.toMillis() - d.createdTimestamp.toMillis()) / 60000 + return acc + responseTimeMinutes + } + return acc + }, 0) + const averageResponseTime = totalResponseTime / (totalCount || 1) + + const accuracyRate = + totalAssessedAndNonUnsureCount === 0 + ? null + : accurateCount / totalAssessedAndNonUnsureCount + + return { + totalVoted, + accuracyRate, + averageResponseTime, + peopleHelped, + } +} + export { checkAccuracy, computeGamificationScore, getFullLeaderboard, computeTimeTakenMinutes, tabulateVoteStats, + computeLast30DaysStats, + computeProgramStats, } diff --git a/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts b/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts index d0169e11..e1a91d81 100644 --- a/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts +++ b/functions/src/definitions/eventHandlers/checkerHandlerWhatsapp.ts @@ -8,7 +8,7 @@ import { sendWhatsappButtonMessage, } from "../common/sendWhatsappMessage" import { getResponsesObj } from "../common/responseUtils" -import { sleep } from "../common/utils" +import { sleep, getThresholds } from "../common/utils" import { sendL1CategorisationMessage, sendRemainingReminder, @@ -16,7 +16,7 @@ import { import { getSignedUrl } from "../common/mediaUtils" import { Timestamp } from "firebase-admin/firestore" import { resetL2Status } from "../common/voteUtils" -import { WhatsappMessageObject, Checker } from "../../types" +import { WhatsappMessageObject, CheckerData } from "../../types" if (!admin.apps.length) { admin.initializeApp() @@ -112,7 +112,8 @@ async function onSignUp(from: string, platform = "whatsapp") { ) return } - const checkerObj: Checker = { + const thresholds = await getThresholds() + const checkerObj: CheckerData = { name: "", type: "human", isActive: true, @@ -126,7 +127,10 @@ async function onSignUp(from: string, platform = "whatsapp") { experience: 0, tier: "beginner", numVoted: 0, + numReferred: 0, + numReported: 0, numCorrectVotes: 0, + numNonUnsureVotes: 0, numVerifiedLinks: 0, preferredPlatform: "whatsapp", getNameMessageId: res.data.messages[0].id, @@ -137,6 +141,20 @@ async function onSignUp(from: string, platform = "whatsapp") { totalTimeTaken: 0, score: 0, }, + programData: { + isOnProgram: true, + programStart: Timestamp.fromDate(new Date()), + programEnd: null, + numVotesTarget: thresholds.volunteerProgramVotesRequirement ?? 0, //target number of messages voted on to complete program + numReferralTarget: thresholds.volunteerProgramReferralRequirement ?? 0, //target number of referrals made to complete program + numReportTarget: thresholds.volunteerProgramReportRequirement ?? 0, //number of non-trivial messages sent in to complete program + accuracyTarget: thresholds.volunteerProgramAccuracyRequirement ?? 0, //target accuracy of non-unsure votes + numVotesAtProgramStart: 0, + numReferralsAtProgramStart: 0, + numReportsAtProgramStart: 0, + numCorrectVotesAtProgramStart: 0, + numNonUnsureVotesAtProgramStart: 0, + }, } await db.collection("checkers").add(checkerObj) } diff --git a/functions/src/definitions/eventHandlers/onCheckerUpdate.ts b/functions/src/definitions/eventHandlers/onCheckerUpdate.ts new file mode 100644 index 00000000..ee7500d0 --- /dev/null +++ b/functions/src/definitions/eventHandlers/onCheckerUpdate.ts @@ -0,0 +1,107 @@ +import { onDocumentUpdated } from "firebase-functions/v2/firestore" +import { CheckerData } from "../../types" +import { computeProgramStats } from "../common/statistics" +import { sendTelegramTextMessage } from "../common/sendTelegramMessage" +import { getResponsesObj } from "../common/responseUtils" +import { logger } from "firebase-functions/v2" + +const checkerAppHost = process.env.CHECKER_APP_HOST + +const onCheckerUpdateV2 = onDocumentUpdated( + { + document: "checkers/{checkerId}", + secrets: [ + "WHATSAPP_USER_BOT_PHONE_NUMBER_ID", + "WHATSAPP_CHECKERS_BOT_PHONE_NUMBER_ID", + "WHATSAPP_TOKEN", + "TYPESENSE_TOKEN", + "TELEGRAM_CHECKER_BOT_TOKEN", + "OPENAI_API_KEY", + ], + }, + async (event) => { + // Grab the current value of what was written to Firestore. + const preChangeSnap = event?.data?.before + const postChangeSnap = event?.data?.after + if (!preChangeSnap || !postChangeSnap) { + return Promise.resolve() + } + const preChangeData = preChangeSnap.data() as CheckerData + const postChangeData = postChangeSnap.data() as CheckerData + + if ( + !postChangeData.programData.isOnProgram || + !postChangeData.programData.programEnd != null + ) { + return Promise.resolve() + } + + if ( + preChangeData.numReferred !== postChangeData.numReferred || + preChangeData.numVoted !== postChangeData.numVoted || + preChangeData.numReported !== postChangeData.numReported || + preChangeData.numCorrectVotes !== postChangeData.numCorrectVotes || + preChangeData.numNonUnsureVotes !== postChangeData.numNonUnsureVotes + ) { + try { + const { + numVotes, + numReferrals, + numReports, + accuracy, + isNewlyCompleted, + } = await computeProgramStats(postChangeSnap) + if ( + isNewlyCompleted && + postChangeData.preferredPlatform === "telegram" && + postChangeData.telegramId + ) { + const telegramId = postChangeData.telegramId + + const url = `${checkerAppHost}/` + const checkerResponses = await getResponsesObj("factChecker") + const baseMessage = checkerResponses.PROGRAM_COMPLETED + if (!baseMessage) { + logger.error( + "No base message found when trying to handle program conclusion completed" + ) + throw new Error("No base message found") + } + const message = checkerResponses.PROGRAM_COMPLETED.replace( + "{{num_messages}}", + numVotes.toString() + ) + .replace("{{num_referred}}", numReferrals.toString()) + .replace("{{num_reported}}", numReports.toString()) + .replace( + "{{accuracy}}", + accuracy === null ? "N/A" : accuracy.toFixed(1) + ) + await sendTelegramTextMessage( + "factChecker", + telegramId, + message, + null, + { + inline_keyboard: [ + [ + { + text: "Get your certificate!", + web_app: { url: url }, + }, + ], + ], + } + ) + } + } catch (error) { + logger.error( + `Error on checker update for ${postChangeSnap.id}: ${error}` + ) + } + } + return Promise.resolve() + } +) + +export { onCheckerUpdateV2 } diff --git a/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts b/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts index bade5a5f..54d64185 100644 --- a/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts +++ b/functions/src/definitions/eventHandlers/onVoteRequestUpdate.ts @@ -31,9 +31,11 @@ const onVoteRequestUpdateV2 = onDocumentUpdated( if (!event?.data?.before || !event?.data?.after) { return Promise.resolve() } - const preChangeData = event.data.before.data() - const postChangeData = event.data.after.data() - const docSnap = event.data.after + const before = event.data.before + const after = event.data.after + const preChangeData = before.data() + const postChangeData = after.data() + const docSnap = after const messageRef = docSnap.ref.parent.parent if (!messageRef) { functions.logger.error(`Vote request ${docSnap.ref.path} has no parent`) @@ -189,51 +191,7 @@ const onVoteRequestUpdateV2 = onDocumentUpdated( } //update leaderboard stats if (preChangeData.isCorrect !== postChangeData.isCorrect) { - const checkerUpdateObj = {} as Record - const previousCorrect = preChangeData.isCorrect - const currentCorrect = postChangeData.isCorrect - const previousScore = preChangeData.score - const currentScore = postChangeData.score - const previousDuration = preChangeData.duration - const currentDuration = postChangeData.duration - let durationDelta = 0 - if (previousDuration != null && previousCorrect != null) { - durationDelta -= previousDuration - } - if (currentDuration != null && currentCorrect != null) { - durationDelta += currentDuration - } - if (durationDelta !== 0) { - checkerUpdateObj["leaderboardStats.totalTimeTaken"] = - FieldValue.increment(durationDelta) - } - - if (previousCorrect === true) { - //means now its not correct - checkerUpdateObj["leaderboardStats.numCorrectVotes"] = - FieldValue.increment(-1) - if (previousScore != null) { - checkerUpdateObj["leaderboardStats.score"] = FieldValue.increment( - -previousScore - ) - } - } - if (currentCorrect === null) { - //means now it's unsure and should not be added to denominator - checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(-1) - } - - if (currentCorrect === true) { - await docSnap.ref.update({ score: currentScore }) - checkerUpdateObj["leaderboardStats.numCorrectVotes"] = - FieldValue.increment(1) - checkerUpdateObj["leaderboardStats.score"] = - FieldValue.increment(currentScore) - } - if (previousCorrect == null) { - checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(1) - } - await postChangeData.factCheckerDocRef.update(checkerUpdateObj) + await updateCheckerCorrectCounts(preChangeData, postChangeData) } return Promise.resolve() } @@ -315,6 +273,67 @@ async function updateCheckerVoteCount( } } +async function updateCheckerCorrectCounts( + before: admin.firestore.DocumentData, + after: admin.firestore.DocumentData +) { + const checkerUpdateObj = {} as Record + const preChangeData = before.data() + const postChangeData = after.data() + if (preChangeData.isCorrect !== postChangeData.isCorrect) { + const previousCorrect = preChangeData.isCorrect + const currentCorrect = postChangeData.isCorrect + const previousScore = preChangeData.score + const currentScore = postChangeData.score + const previousDuration = preChangeData.duration + const currentDuration = postChangeData.duration + let durationDelta = 0 + if (previousDuration != null && previousCorrect != null) { + durationDelta -= previousDuration + } + if (currentDuration != null && currentCorrect != null) { + durationDelta += currentDuration + } + if (durationDelta !== 0) { + checkerUpdateObj["leaderboardStats.totalTimeTaken"] = + FieldValue.increment(durationDelta) + } + + if (previousCorrect === true) { + //means now its not correct + checkerUpdateObj["leaderboardStats.numCorrectVotes"] = + FieldValue.increment(-1) + checkerUpdateObj["numCorrectVotes"] = FieldValue.increment(-1) + if (previousScore != null) { + checkerUpdateObj["leaderboardStats.score"] = FieldValue.increment( + -previousScore + ) + } + } + if (currentCorrect === null) { + //means now it's unsure and should not be added to denominator + checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(-1) + checkerUpdateObj["numNonUnsureVotes"] = FieldValue.increment(-1) + } + + if (currentCorrect === true) { + await after.ref.update({ score: currentScore }) + checkerUpdateObj["leaderboardStats.numCorrectVotes"] = + FieldValue.increment(1) + checkerUpdateObj["numCorrectVotes"] = FieldValue.increment(1) + checkerUpdateObj["leaderboardStats.score"] = + FieldValue.increment(currentScore) + } + if (previousCorrect == null) { + checkerUpdateObj["leaderboardStats.numVoted"] = FieldValue.increment(1) + checkerUpdateObj["numNonUnsureVotes"] = FieldValue.increment(1) + } + await after.factCheckerDocRef.update(checkerUpdateObj) + } else { + functions.logger.warn("Correct status did not change") + } +} + async function switchLegacyCheckerRef( factCheckerDocRef: admin.firestore.DocumentReference ) { diff --git a/functions/src/definitions/webhookHandlers/specialCommands.ts b/functions/src/definitions/webhookHandlers/specialCommands.ts index b46d0a49..76077a7d 100644 --- a/functions/src/definitions/webhookHandlers/specialCommands.ts +++ b/functions/src/definitions/webhookHandlers/specialCommands.ts @@ -1,7 +1,7 @@ import * as functions from "firebase-functions" import * as admin from "firebase-admin" import { defineString } from "firebase-functions/params" -import { WhatsappMessage, Checker } from "../../types" +import { WhatsappMessage, CheckerData } from "../../types" import { sendWhatsappTextMessage } from "../common/sendWhatsappMessage" import USER_BOT_RESPONSES from "../common/parameters/userResponses.json" import CHECKER_BOT_RESPONSES from "../common/parameters/checkerResponses.json" @@ -83,7 +83,7 @@ const mockDb = async function () { .where("whatsappId", "==", checker1PhoneNumber.value()) .limit(1) .get() - const checkerObj: Checker = { + const checkerObj: CheckerData = { name: "CHECKER1", type: "human", isActive: true, @@ -97,7 +97,10 @@ const mockDb = async function () { experience: 0, tier: "expert", numVoted: 0, + numReferred: 0, + numReported: 0, numCorrectVotes: 0, + numNonUnsureVotes: 0, numVerifiedLinks: 0, preferredPlatform: "whatsapp", lastVotedTimestamp: null, @@ -108,6 +111,20 @@ const mockDb = async function () { totalTimeTaken: 0, score: 0, }, + programData: { + isOnProgram: true, + programStart: Timestamp.fromDate(new Date()), + programEnd: null, + numVotesTarget: thresholds.volunteerProgramVotesRequirement ?? 0, //target number of messages voted on to complete program + numReferralTarget: thresholds.volunteerProgramReferralRequirement ?? 0, //target number of referrals made to complete program + numReportTarget: thresholds.volunteerProgramReportRequirement ?? 0, //number of non-trivial messages sent in to complete program + accuracyTarget: thresholds.volunteerProgramAccuracyRequirement ?? 0, //target accuracy of non-unsure votes + numVotesAtProgramStart: 0, + numReferralsAtProgramStart: 0, + numReportsAtProgramStart: 0, + numCorrectVotesAtProgramStart: 0, + numNonUnsureVotesAtProgramStart: 0, + }, } if (querySnap.empty) { await checkersCollectionRef.add(checkerObj) diff --git a/functions/src/types.ts b/functions/src/types.ts index ce4a0be4..6adf132f 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -38,7 +38,7 @@ export type WhatsappMessageObject = { image?: { caption: string; id: string; mime_type: string } } -export type Checker = { +export type CheckerData = { name: string type: "human" | "ai" isActive: boolean | null @@ -51,13 +51,17 @@ export type Checker = { experience: number tier: "beginner" | "intermediate" | "expert" numVoted: number + numReferred: number + numReported: number voteWeight: number numCorrectVotes: number + numNonUnsureVotes: number numVerifiedLinks: number preferredPlatform: string | null lastVotedTimestamp: Timestamp | null getNameMessageId: string | null leaderboardStats: LeaderBoardStats + programData: ProgramData } type LeaderBoardStats = { @@ -67,6 +71,21 @@ type LeaderBoardStats = { score: number // total score } +export type ProgramData = { + isOnProgram: boolean + programStart: Timestamp | null + programEnd: Timestamp | null + numVotesTarget: number //target number of messages voted on to complete program + numReferralTarget: number //target number of referrals made to complete program + numReportTarget: number //number of non-trivial messages sent in to complete program + accuracyTarget: number //target accuracy of non-unsure votes + numVotesAtProgramStart: number + numReferralsAtProgramStart: number + numReportsAtProgramStart: number + numCorrectVotesAtProgramStart: number + numNonUnsureVotesAtProgramStart: number +} + export type LeaderboardEntry = { id: string position: number