From 91f47f01a5f6c704fda829ccfe8d38ec771bbfe8 Mon Sep 17 00:00:00 2001 From: "Peter.Morton" Date: Wed, 3 Sep 2025 14:47:16 -0500 Subject: [PATCH] Adding context updater example --- .../ProxyScript/context-updater.js | 360 ++++++++++++++++++ .../context-updater.posting.yaml | 43 +++ 2 files changed, 403 insertions(+) create mode 100644 copilot/_studio_dependencies/ProxyScript/context-updater.js create mode 100644 copilot/posting/context-updater/context-updater.posting.yaml diff --git a/copilot/_studio_dependencies/ProxyScript/context-updater.js b/copilot/_studio_dependencies/ProxyScript/context-updater.js new file mode 100644 index 0000000..6bb6ec9 --- /dev/null +++ b/copilot/_studio_dependencies/ProxyScript/context-updater.js @@ -0,0 +1,360 @@ +console.log({ query: req.query, params: req.params, body: req.body }); + +const msgrSettings = settings_667ef98d3ee8930a5debcbdb; +const paSettings = proactive_agent_settings; +const studioToken = msgrSettings + ? { + Authorization: `Bearer ${msgrSettings.messenger.token}`, + } + : {}; +const channel = req.body?.metadata?.channel | "copilot"; + +let bot_user = { + ...paSettings.messenger.user, +}; + +const stripHTMLTags = (input) => { + if (typeof input !== "string") { + throw new Error("Input must be a string"); + } + return input.replace(/<\/?[^>]+(>|$)/g, ""); // Matches and removes HTML tags +} + +const app = { + routes: [], + route: function (regexp, fn) { + this.routes.push({ regexp: new RegExp(regexp), function: fn }); + }, + run: function (req, res) { + const path = + req.params[0].split( + `/ProxyScript/run/${req.params.workspaceId}/${req.params.branch}/${req.params.route}` + )[1] || "/"; + + for (var route of this.routes) { + console.log(`Checking route: ${route.regexp}`); + console.log(`Against path: ${path}`); + if (route.regexp.test(path)) { + console.log(`Matched route: ${route.regexp}`); + route.function(req, res, route.regexp.exec(path)); + return; + } + } + // If no route matches, return a 404 status + const status = { path: req.params[0], status: 404 }; + res.status(404).send(status); + }, +}; + +const redisKey = (userId) => { + return `user-map-${userId}`; +}; + +const axioshelper = (message, config, callback, retries = 3, delay = 100) => { + console.log(config); + return new Promise(async (resolve, reject) => { + for (let i = 0; i < retries; i++) { + try { + const response = await axios(config); + + if (response.status < 200 || response.status > 299) { + if (callback) { + await callback(); + } else { + throw new Error( + `Axios Request failed! ${JSON.stringify(response)}` + ); + } + } + resolve(response); + break; + } catch (error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + console.log(error.response.data); + console.log(error.response.status); + console.log(error.response.headers); + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log("Error", error.message); + } + console.log(error.config); + if (i === retries - 1) { + if (callback) { + await callback(); + } + console.log({ + retry: i + 1, + error: "out of retries", + message, + url: config.url, + }); + reject(error); + } else { + console.log({ + level: "warn", + message: `Attempt ${i + 1} failed.`, + url: config.url, + }); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + }); +}; + +async function createSessionMap(umStr, sessionMap) { + console.log({ method: "createSessionMap", sessionMap }); + let keys = Object.keys(sessionMap); + keys.forEach(async (key) => { + await redis.hSet(umStr, key, sessionMap[key]); + }); + await redis.expire(umStr, 3600); +} + +async function m2mAuthentication() { + let data = new URLSearchParams({ + grant_type: paSettings.copilot.oauth2.grant_type, + client_id: paSettings.copilot.oauth2.clientId, + client_secret: paSettings.copilot.oauth2.clientSecret, + audience: paSettings.copilot.oauth2.audience, + }); + + let token = await axioshelper("m2m authentication", { + method: "post", + url: paSettings.copilot.tokenUrl, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data, + }) + .then((response) => { + return response.data.access_token; + }) + .catch((error) => { + console.error("Error during M2M authentication:", error); + throw error; + }); + + return token; +} + +app.route( + "^(?:/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/contexts/([^/]+))(?:/$)?$", + async (req, res, match) => { + try { + console.log(`Received request for contexts: ${match}`); + + const modelName = + req.params?.modelName || context_updater_settings.modelName; + + // expect 2 matches + if (match.length > 2) { + const tenantId = match[1]; + const contextOwner = match[2]; + const smStr = redisKey(contextOwner); + let sessionMap = await redis.hGetAll(smStr); + + if (!sessionMap || !sessionMap.conversationId) { + // no session data is okay for copilot, create one now + sessionMap = { + customerId: paSettings.copilot.customerId, + upn: contextOwner, + tenantId, + }; + await createSessionMap(smStr, sessionMap); + } + console.log({ sessionMap }); + + const metadata = { + userId: bot_user.userId, + channel, + }; + + let input = undefined; + req.body["vcx:fields"].forEach((field) => { + if (field["vcx:field"] == "searchTerm") { + input = "Search for " + field["vcx:typedValue"]["vcx:stringValue"]; + } + }); + if (input) { + const recognizedData = await axioshelper("run model", { + method: "post", + url: `${settings_667ef98d3ee8930a5debcbdb.nlu.apiBaseURL}Model/run/${req.params.workspaceId}/${req.params.branch}/${modelName}`, + headers: studioToken, + data: { + input: input, + conversationId: sessionMap.conversationId, + settings: settings_667ef98d3ee8930a5debcbdb.nlu.settings, + metadata: metadata, + }, + }) + .then((response) => { + return response.data; + }) + .catch((error) => { + //If we have error handling turned off, don't record the error + if (settings_667ef98d3ee8930a5debcbdb.errorHandling === false) { + throw error; + } else { + // Conversation ID is generated by the NLU + // Must make sure Conversation ID is created properly for an error transaction + // Get it from session map if it exists, or generate it if we got the error at the very first transaction. + const conversationId = sessionMap.conversationId || uuidv4(); + let errorPayload = { + id: uuidv4(), + workspaceId: req.params.workspaceId, + errorInfo: { + name: error.name, + message: error.message, + stack: error.stack, + }, + conversationId, + input: req.body.input, + answers: [ + settings_667ef98d3ee8930a5debcbdb.responses.modelError, + ], + req: { + params: req.params, + query: req.query, + body: req.body, + headers: req.headers, + }, + metadata, + classificationResults: [ + { + label: "Model Error", + value: 0.0, + }, + ], + nerResults: { + entities: [], + sourceEntities: [], + }, + // Reporting tags to match GlobalSupport function reporting + tag: "ERROR", + reporting: { + tag: "ERROR", + modelResponse: "ERROR", + }, + }; + + // Add model name + errorPayload.req.params.route = modelName; + + //add any other details you wish to see in transaction data + + return errorPayload; + } + }); + + if (recognizedData.conversationId) { + await redis.hSet( + smStr, + "conversationId", + recognizedData.conversationId + ); + await redis.expire(smStr, 3600); + if (sessionMap.conversationId) { + } else { + db.analytics.addConversation({ + id: recognizedData.conversationId, + }); + } + } + recognizedData._id = ( + await db.analytics.addTransaction(recognizedData) + ).insertedId; + + // Send Answers to Messenger + new Promise(async (resolve) => { + for (const [index, answer] of recognizedData.answers.entries()) { + let bearerAuth = await m2mAuthentication(); + if (!bearerAuth) { + throw Error("Unable to Authenticate M2M"); + } + + let body = answer; + + if (typeof answer === "string" || answer instanceof String) { + + body = stripHTMLTags(body); + + body = { + source: "//wa/us-east-1/int/demo/plain", + type: "verint.ui_messages.text.v1", + id: "ca5057b8-2c1d-4758-89d6-0370d6f28cc2", + time: "2020-07-30T14:44:00+00:00", + data: { + version: "1.0.0", + format: "plain", + persona_icon: { + id: "Verint-Bot-Head", + color: "#007ACC", + }, + title_icon: { + id: "message-status-info", + color: "#FF5722", + }, + body: body, + }, + }; + } + + console.log({ + data: { + customerId: sessionMap.customerId, + upn: sessionMap.upn, + ...body, + }, + }); + + let cloudEventResponse = await axioshelper( + "copilot cloud-event", + { + method: "post", + url: `https://apigw.us-east-1.wrk-1.aws.hydra.verint.com/int/wa/v1/cloud-event`, + headers: { + Authorization: `Bearer ${bearerAuth}`, + }, + data: { + customerId: sessionMap.customerId, + upn: sessionMap.upn, + ...body, + }, + } + ); + console.log({ cloudEventResponse: cloudEventResponse.data }); + } + + resolve(true); + }); + + // scrub response before sending + delete recognizedData.req; + + res.send(recognizedData); + } else { + res.status(400).send({ error: "Bad Request no search term found." }); + } + } else { + res + .status(400) + .send({ error: "Bad Request no context owner or tenantId found." }); + } + } catch (error) { + console.error(error); + return res.status(500).send({ + status: 500, + }); + } + } +); + +app.run(req, res); diff --git a/copilot/posting/context-updater/context-updater.posting.yaml b/copilot/posting/context-updater/context-updater.posting.yaml new file mode 100644 index 0000000..c897543 --- /dev/null +++ b/copilot/posting/context-updater/context-updater.posting.yaml @@ -0,0 +1,43 @@ +name: Context Updater +description: |- + Simulates the call that is made to KME's Context Updater + See documentation here; https://em-docs.verint.com/15_3/em-knowledge-management/Content/Contextual_Knowledge/Updating_Suggested_Knowledge_Using_the_Context_Updater_API.htm +method: POST +url: https://router.ivastudio.verint.live/ProxyScript/run/67bca862210071627d32ef12/current/context-updater/$CUSTOMER_ID/contexts/$UPN +body: + content: |- + { + "@type": "vcx:ComplexContextUpdateRequest", + "vcx:entityDefinitionName": "KnowledgeSearchContextED", + "vcx:contextKey": "knowledgeSearchContext", + "vcx:fields" : [ + { + "@type" : "vcx:ContextField", + "vcx:field" : "searchTerm", + "vcx:typedValue" : { + "@type": "vcx:StringValue", + "vcx:stringValue" : "pet policy" + } + }, + { + "@type" : "vcx:ContextField", + "vcx:field" : "tags", + "vcx:typedValue" : { + "@type": "vcx:StringValue", + "vcx:stringValue" : "content_alert" + } + }, + { + "@type" : "vcx:ContextField", + "vcx:field" : "locale", + "vcx:typedValue" : { + "@type": "vcx:StringValue", + "vcx:stringValue" : "en-US" + } + } + ] + } + content_type: application/json +headers: +- name: content-type + value: application/json