projects/making_ai_transparent_and_accountable/rappler_aidialogue/functions/index.js (353 lines of code) (raw):
const logger = require("firebase-functions/logger");
const {onDocumentCreated} = require("firebase-functions/v2/firestore");
// The Firebase Admin SDK
// - to access Firestore.
const {initializeApp} = require("firebase-admin/app");
const {
getAuth,
} = require("firebase-admin/auth");
const {getFirestore, Timestamp} = require("firebase-admin/firestore");
// const {Timestamp} = require("firebase-admin/firestore");
// Dependencies for callable and scheduled functions.
const {onCall} = require("firebase-functions/v2/https");
const {onSchedule} = require("firebase-functions/v2/scheduler");
const FGDAIprompts = require("./prompts");
const chatGPT = require("./chatgpt");
const moderator = require("./moderation");
initializeApp();
// get firestore instance
const db = getFirestore();
// get auth instance
const auth = getAuth();
const AI_MODERATOR_NAME = "Rai";
const aierrors = [
`Oops! ${AI_MODERATOR_NAME} got distracted by a mouse...
Let's give her a moment to compose herself.`,
`${AI_MODERATOR_NAME} just went to the bathroom.
She'll be back soon.`,
`Looks like ${AI_MODERATOR_NAME} dozed off.
Please stay tuned while we give her a nudge.`,
];
const AI_TIMEOUT_SECONDS = 540;
const aiSaid = async (session, message) => {
await db.collection("aistatus").add({
session: session,
message: message,
createdAt: Timestamp.now(),
});
};
// - Listens for new messages added to /prompts/:promptId/prompt
// and sends the prompt to a selected llm.
// - Waits for the response and saves to /prompts/:promptId/response
//
// ***Note: To run locally,
// create a .secret.local file with your key in OPENAI_API_KEY
//
// ***Note: To test locally, use method signature below
// exports.talkToAI = onCall(
// {
// timeoutSeconds: 540,
// memory: "1GiB",
// secrets: ["OPENAI_API_KEY"],
// },
// async (request) => {
// const data = request.data;
// const promptDoc = await db.collection("prompts")
// .doc(data.promptId)
// .get();
// const prompt = promptDoc.data();
exports.talkToAI = onDocumentCreated(
{
document: "/prompts/{promptId}",
timeoutSeconds: AI_TIMEOUT_SECONDS,
memory: "1GiB",
secrets: ["OPENAI_API_KEY"],
},
async (event) => {
// Grab the current value of what was written to Firestore.
const prompt = event.data.data();
logger.debug("talkToAI start.");
let response = "";
// Check createdAt. If it has been more then 10 minutes, do not process
const secondsElapsed = Timestamp.now() - prompt.createdAt;
if (secondsElapsed > AI_TIMEOUT_SECONDS) {
response = "ERROR: Cancelled by moderator";
return event.data.ref.set({response}, {merge: true});
} else {
switch (prompt.type) {
case "followup": {
response = await chatGPT.sendFollowupPrompt(prompt, db);
break;
}
case "summary": {
response = await chatGPT.sendSummaryPrompt(prompt, db);
break;
}
case "policy": {
response = await chatGPT.sendPolicyPrompt(prompt, db);
break;
}
}
if (!response) {
await aiSaid(prompt.session,
aierrors[Math.floor(Math.random() * aierrors.length)]);
}
return event.data.ref.set({response}, {merge: true});
}
},
);
// - must be provided a questionId of a MAIN question
// - will check for followup questions if any
// - prompt AI to generate follow up questions based on current discussion
// - write questions into the questions collection in firestore
// - data.questionId: string, id of a question doc with type "main"
exports.generateQuestion = onCall(// { enforceAppCheck: true, },
async (request) => {
logger.debug(`generateQuestion start.`);
const data = request.data;
if (data.questionId) {
// Question ID passed is always a top-level question.
// Get possible follow up questions being discussed already.
const questionDoc = await db.collection("questions")
.doc(data.questionId).get();
aiSaid(questionDoc.data().session, `Thank you for your insights!
${AI_MODERATOR_NAME} may send some follow-up questions
based on points raised by the group. Please stay tuned.`);
await FGDAIprompts.getFollowup(questionDoc, db);
}
return;
},
);
// - must be provided the question uuid
// - query all responses for given question
// - prompt AI to generate a summary of the responses
// - write the summary to the summary attribute
// of question doc in firestore
exports.summarizeResponses = onCall(// { enforceAppCheck: true, },
async (request) => {
logger.debug("summarizeResponses start.");
const data = request.data;
if (data.questionId) {
const questionDoc = await db
.collection("questions")
.doc(data.questionId)
.get();
aiSaid(questionDoc.data().session, `Awesome responses!
${AI_MODERATOR_NAME} is summarizing your inputs.
This may take a few minutes. Please hold tight!`);
// Get the minRespondersForSummary value for this session
const sessionDoc = await db.collection("sessions")
.doc(questionDoc.data().session)
.get();
let minRespondersForSummary = 10;
if (sessionDoc.get("minRespondersForSummary")) {
minRespondersForSummary = sessionDoc.data().minRespondersForSummary;
}
const followupQuestions = await db.collection("questions")
.where("parentId", "==", questionDoc.id)
.where("type", "==", "followup")
.orderBy("seq", "desc")
.get();
for (const followup of followupQuestions.docs) {
await FGDAIprompts.getSummary(followup, db, minRespondersForSummary);
}
await FGDAIprompts.getSummary(questionDoc, db, minRespondersForSummary);
}
return;
},
);
// - does not need any parameter
// - creates the prompt for generating polices based on all responses
// to all questions (main and followup)
exports.generatePolicies = onCall(// { enforceAppCheck: true, },
async (request) => {
logger.debug("generatePolicies start.");
const data = request.data;
if (data.session) {
// Check if all questions have summaries.
// This ensures there are responses.
let proceed = true;
const mainQuestions = await db.collection("questions")
.where("session", "==", data.session.id)
.where("type", "==", "main")
.get();
for (const question of mainQuestions.docs) {
if (question.get("summary") === undefined) {
proceed = false;
break;
}
}
if (proceed) {
const followupQuestions = await db.collection("questions")
.where("session", "==", data.session.id)
.where("type", "==", "followup")
.get();
for (const question of followupQuestions.docs) {
if (question.get("summary") === undefined) {
proceed = false;
break;
}
}
}
if (proceed) {
aiSaid(data.session.id, `This might be a good time to get some
coffee or do some stretches? ${AI_MODERATOR_NAME} is thinking
of policies based on your insights and suggestions. We'd
love to get your reactions to what she comes up with.
This may take a minute or so...`);
await FGDAIprompts.getPolicyFromSummaries(data.session.id, db);
}
}
return;
},
);
exports.registerSessionUser = onCall(async (request) => {
let response = {};
const data = request.data;
// console.log("registersessionUser", data);
const {session, code, initials, gender, birthDate} = data;
let {email=""} = data;
// check if code matches the code for the session
const sessionDoc = await db.collection("sessions").doc(session).get();
const sessionData = sessionDoc.data();
// console.log("session data", sessionData);
if (sessionData.code !== code) {
// throw new Error("Invalid code");
response = {
success: false,
field: "code",
message: "The code you entered is invalid for this session",
};
} else {
try {
// look for user with given initials, gender and birthDate
let userDoc;
if (email !== "") {
userDoc = await db
.collection("users")
.where("initials", "==", initials)
.where("gender", "==", gender)
.where("birthDate", "==", birthDate)
.where("email", "==", email)
.limit(1)
.get();
} else {
userDoc = await db
.collection("users")
.where("initials", "==", initials)
.where("gender", "==", gender)
.where("birthDate", "==", birthDate)
.limit(1)
.get();
}
const success = true;
let existing = true;
let uid;
if (userDoc.docs[0]) {
// user exists
uid = userDoc.docs[0].id;
} else {
// user does not exist
// create the user with firebase auth
const rndm = Math.floor(Math.random() * 1000000);
if (email === "") {
email = `${initials}-${rndm}@fgdai.net`;
}
const user = await auth.createUser({
email,
password: `passFgdai-${rndm}`,
});
// create user record in firestore
db.doc(`/users/${user.uid}`).set({
initials,
birthDate,
gender,
email,
sessions: [session],
uid: user.uid,
status: "online",
});
existing = false;
uid = user.uid;
}
// generate a custom token
const token = await auth.createCustomToken(uid);
// build the response
response = {
success,
existing,
uid,
token,
};
} catch (err) {
// build the error response
response = {
success: false,
field: "email",
message: err.message,
};
}
}
return response;
});
// This contains the rules for when one of the other functions
// are to be called (e.g. when to get follow up questions).
// It monitors a queue for ongoing "jobs" such as generating
// a follow up question.
exports.moderateChat = onSchedule("* * * * *", async (event) => {
logger.debug("moderateChat start.");
// Get a list of sessions that are automated.
const sessions = await db.collection("sessions")
.where("automated", "==", true)
.get();
for (const session of sessions.docs) {
logger.debug(`Assessing ${session.id}`);
let canModerate = true;
// check if there is an existing job in progress. assuming we can only
// have 5 concurrent prompts for each session (e.g. followup, summarize)
const prompts = await db.collection("prompts")
.where("session", "==", session.id)
.orderBy("createdAt", "desc")
.limit(5)
.get();
for (const prompt of prompts.docs) {
if (prompt.get("response") === undefined) {
// check how long this has been running if it has exceeded the timeout
// we can reset and we expect the algo to arrive at the same step.
// If it hasn't exceeded the time limit, we can't moderate.
const secondsElapsed = Timestamp.now() - prompt.data().createdAt;
if (secondsElapsed < AI_TIMEOUT_SECONDS) {
logger.debug("Giving current prompt time to complete or timeout.");
canModerate = false;
break;
} else {
// this prompt has taken too long and should be "cancelled"
await db.collection("prompts")
.doc(prompt.id)
.update({
response: "ERROR: Cancelled by moderator",
});
logger.debug(`Cancelled propmt. ${prompt.id}`);
}
}
}
if (canModerate) {
logger.debug(`Moderating ${session.data().title}`);
// Get the current state of this fgd session
const currentQuestion = await moderator
.getCurrentQuestion(session.id, db);
if (currentQuestion) {
logger.debug(`Active question: ${currentQuestion.id}`);
// Rules for Generating Follow Up Questions
let allotedMinutes = 3; // three minutes per question
let allotedFollowups = 3; // up to three follow ups per question
let minRespondersForSummary = 10;
if (session.get("minutesPerQuestion")) {
allotedMinutes = session.data().minutesPerQuestion;
}
if (session.get("followupsPerQuestion")) {
allotedFollowups = session.data().followupsPerQuestion;
}
if (session.get("minRespondersForSummary")) {
minRespondersForSummary = session.data().minRespondersForSummary;
}
// Get the time of the first response to this question
const responses = await db.collection("responses")
.where("questionId", "==", currentQuestion.id)
.orderBy("createdAt")
.limit(1)
.get();
if (responses.docs[0]) {
const secondsElapsed = Timestamp.now() -
responses.docs[0].data().createdAt;
logger.debug(`It has been ${secondsElapsed}
since the first response`);
if (secondsElapsed >= allotedMinutes * 60) {
// Ok, it's been 3 minutes now.
// Check if current question is already a follow up
if (currentQuestion.data().type == "followup") {
if (currentQuestion.data().seq < allotedFollowups) {
logger.debug(`Getting followup #
${currentQuestion.data().seq + 1}`);
const questionDoc = await db.collection("questions")
.doc(currentQuestion.data().parentId).get();
aiSaid(questionDoc.data().session, `Thank you for your
insights! ${AI_MODERATOR_NAME} may send some follow-up
questions based on points raised by the group.
Please stay tuned.`);
await FGDAIprompts.getFollowup(questionDoc, db);
} else {
logger.debug("Showing the next question.");
// We've reached the limit for follow ups, make the next
// main question visible.
await moderator.showMainQuestion(session.id, db);
}
} else {
logger.debug(`Getting followup #1`);
// This is a main question. Request for a follow up.
aiSaid(currentQuestion.data().session, `Thank you for your
insights! ${AI_MODERATOR_NAME} may send some follow-up
questions based on points raised by the group.
Please stay tuned.`);
await FGDAIprompts.getFollowup(currentQuestion, db);
}
// Loop through all visible questions for this session
// and update those with existing summaries.
const questions = await db.collection("questions")
.where("session", "==", session.id)
.where("visible", "==", true)
.get();
let hasOneSummary = false;
let summariesComplete = true;
let noPoliciesYet = true;
for (const question of questions.docs) {
// Generate summary only for main and followup types
if (question.data().type == "main" ||
question.data().type == "followup") {
if (question.get("summary") == undefined) {
summariesComplete = false;
} else {
hasOneSummary = true;
}
await FGDAIprompts.getSummary(question, db,
minRespondersForSummary);
}
if (noPoliciesYet && question.data().type == "policycheck") {
noPoliciesYet = false;
}
}
if (summariesComplete && hasOneSummary && noPoliciesYet) {
await FGDAIprompts.getPolicyFromSummaries(session.id, db);
}
} // else we do nothing, giving more users time to respond
} // else we do nothing, we're still waiting for a response
} else {
// No visible questions yet. Determine if we can start based on the
// number of online users.
logger.log("No visible question. Checking for online users...");
const counts = await moderator.getUserCounts(session.id, db);
// TODO: num of online users required to start should be configurable
// If we have X number of online users, make the first question visible
if (counts.online >= 1) {
await moderator.showMainQuestion(session.id, db);
}
}
}
}
});