wingbot
Version:
Enterprise Messaging Bot Conversation Engine
397 lines (335 loc) • 10.8 kB
JavaScript
/**
* @author David Menger
*/
'use strict';
const { getSetState } = require('./utils/getUpdate');
const { PHONE_REGEX, EMAIL_REGEX } = require('./systemEntities/regexps');
const getCondition = require('./utils/getCondition');
const stateData = require('./utils/stateData');
const Ai = require('./Ai');
// const getCondition = require('./utils/getCondition');
/** @typedef {import('./Responder')} Responder */
/** @typedef {import('./AiMatching').PreprocessorOutput} PreprocessorOutput */
/** @typedef {import('./Request')} Request */
/** @typedef {import('./Responder').Persona} Persona */
/** @typedef {import('./Router').BaseConfiguration} BaseConfiguration */
/** @typedef {import('./LLMSession').LLMMessage<any>} LLMMessage */
/** @typedef {import('./LLMSession').ToolCall} ToolCall */
/** @typedef {import('./LLMSession').LLMRole} LLMRole */
/** @typedef {import('./LLMSession').FilterScope} FilterScope */
/** @typedef {import('./LLMSession')} LLMSession */
/** @typedef {import('./transcript/transcriptFromHistory').Transcript} Transcript */
/** @typedef {import('./utils/getCondition').ConditionDefinition} ConditionDefinition */
/** @typedef {import('./utils/getCondition').ConditionContext} ConditionContext */
/** @typedef {import('./utils/stateData').IStateRequest} IStateRequest */
/** @typedef {string|'_DISCARD'} EvaluationRuleAction */
/**
* @typedef {object} EvaluationRuleData
* @prop {EvaluationRuleAction} [action]
* @prop {object} [setState]
*/
/**
* @typedef {object} RuleDefinitionData
* @prop {string[]} aiTags
* @prop {EvaluationRuleAction} [targetRouteId]
*/
/**
* @typedef {EvaluationRuleData & RuleDefinitionData & ConditionDefinition} EvaluationRule
*/
/**
* @typedef {object} PrepocessedRuleData
* @prop {Function} condition
* @prop {PreprocessorOutput} rule
*/
/**
* @typedef {EvaluationRuleData & PrepocessedRuleData} PreprocessedRule
*/
/**
* @typedef {object} RuleScore
* @prop {number} score
*/
/**
* @typedef {RuleScore & PreprocessedRule} RuleWithScore
*/
/**
* @typedef {object} EvaluationResult
* @prop {string} action
* @prop {boolean} discard
* @prop {RuleWithScore[]} results
* @prop {object} setState
*/
/**
* @callback LLMChatProviderPrompt
* @param {LLMMessage[]} prompt
* @param {LLMProviderOptions} [options]
* @returns {Promise<LLMMessage>}
*/
/**
* @typedef {object} LLMProviderOptions
* @prop {string} [model]
*/
/**
* @typedef {object} LLMLogOptions
* @prop {VectorSearchResult} [vectorSearchResult]
*/
/**
* @typedef {object} LLMChatProvider
* @prop {LLMChatProviderPrompt} requestChat
*/
/** @typedef {import('node-fetch').default} Fetch */
/**
* @typedef {object} LLMConfiguration
* @prop {LLMChatProvider} provider
* @prop {string} [model]
* @prop {number} [transcriptLength=-5]
* @prop {'gpt'|string} [transcriptFlag]
* @prop {boolean} [transcriptAnonymize]
* @prop {Persona|string|null} [persona]
* @prop {LLMLogger} [logger]
*/
/**
* @typedef {object} AnonymizeRegexp
* @prop {string} [replacement]
* @prop {RegExp} regex
*/
/**
* @typedef {object} PromptInfo
* @prop {LLMMessage[]} prompt
* @prop {LLMMessage} result
* @prop {VectorSearchResult} [vectorSearchResult]
*/
/**
* @callback LogPrompt
* @param {PromptInfo} info
*/
/**
* @typedef {object} LLMLogger
* @prop {LogPrompt} logPrompt
*/
/**
* @typedef {object} VectorSearchDocument
* @property {string} id
* @property {string} name
* @property {string} text
* @property {number} cosineDistance
* @property {boolean} excludedByCosineDistanceThreshold
*/
/**
* @typedef {object} VectorSearchResult
* @property {number} maximalCosineDistanceThreshold
* @property {number} nearestNeighbourCount
* @property {VectorSearchDocument[]} resultDocuments
*/
/**
* @class LLM
*/
class LLM {
/** @type {LLMRole} */
static ROLE_USER = 'user';
/** @type {LLMRole} */
static ROLE_ASSISTANT = 'assistant';
/** @type {LLMRole} */
static ROLE_SYSTEM = 'system';
static GPT_FLAG = 'gpt';
/** @type {FilterScope} */
static FILTER_SCOPE_CONVERSATION = 'conversation';
static EVALUATION_ACTIONS = {
DISCARD: '_DISCARD'
};
/** @type {AnonymizeRegexp[]} */
static anonymizeRegexps = [
{ replacement: '@PHONE', regex: new RegExp(PHONE_REGEX.source, 'g') },
{ replacement: '@EMAIL', regex: new RegExp(EMAIL_REGEX.source, 'g') }
];
/**
*
* @param {LLMConfiguration} configuration
* @param {Ai} ai
*/
constructor (configuration, ai) {
const { provider, ...rest } = configuration;
this._configuration = {
transcriptFlag: null,
transcriptLength: 5,
provider: null,
logger: {
logPrompt: () => {}
},
...rest
};
/** @type {LLMChatProvider} */
this._provider = provider;
this._ai = ai;
/** @type {LLMMessage} */
this._lastResult = null;
}
/**
* @returns {Ai}
*/
get ai () {
return this._ai;
}
/**
* @returns {LLMMessage}
*/
get lastResult () {
return this._lastResult;
}
/**
* @returns {Omit<LLMConfiguration, 'provider'>}
*/
get configuration () {
return this._configuration;
}
/**
*
* @param {Transcript[]} chat
* @param {boolean} [transcriptAnonymize]
* @returns {LLMMessage[]}
*/
static anonymizeTranscript (chat, transcriptAnonymize) {
return chat.map((c) => ({
role: c.fromBot ? LLM.ROLE_ASSISTANT : LLM.ROLE_USER,
content: transcriptAnonymize
? LLM.anonymizeRegexps
.reduce((text, { replacement, regex }) => {
const replaced = text.replace(regex, replacement);
return replaced;
}, c.text)
: c.text
}));
}
/**
*
* @param {LLMSession} session
* @param {LLMProviderOptions} [options={}]
* @param {LLMLogOptions} [logOptions]
* @returns {Promise<LLMMessage>}
*/
async generate (session, options = {}, logOptions = {}) {
/** @type {LLMProviderOptions} */
const opts = {
...(this._configuration.model && { model: this._configuration.model }),
...options
};
const prompt = session.toArray(true);
const result = await this._provider.requestChat(prompt, opts);
this.logPrompt(prompt, result, logOptions.vectorSearchResult);
return result;
}
/**
*
* @param {LLMMessage[]} prompt
* @param {LLMMessage} result
* @param {VectorSearchResult} [vectorSearchResult]
*/
logPrompt (prompt, result, vectorSearchResult) {
this._lastResult = result;
this._configuration.logger.logPrompt({
prompt, result, vectorSearchResult
});
}
/**
*
* @param {LLMMessage} result
* @returns {LLMMessage[]}
*/
static toMessages (result) {
let filtered = result.content
.replace(/\n\n\n+/g, '\n\n')
.split(/\n\n+(?!\s*-)/g)
.map((t) => t.replace(/\s*\n\s+/g, '\n')
.trim())
.filter((t) => !!t);
if (result.finishReason === 'length' && filtered.length <= 0) {
filtered = filtered.slice(0, filtered.length - 1);
}
return filtered.map((content) => ({
content,
role: result.role
}));
}
/**
*
* @param {EvaluationRule[]} rules
* @param {ConditionContext} [context]
* @returns {PreprocessedRule[]}
*/
static preprocessEvaluationRules (rules, context = {}) {
const {
linksMap = new Map(),
ai = Ai.ai
} = context;
return rules.map((evalRule) => {
const { aiTags, targetRouteId, ...rest } = evalRule;
const condition = getCondition(rest, context);
const rule = ai.matcher.preprocessRule(aiTags);
let { action = null } = evalRule;
if (!action && targetRouteId && linksMap.has(targetRouteId)) {
action = linksMap.get(targetRouteId);
}
return {
...rest,
condition,
rule
};
});
}
/**
* Returns all actions, which has been recognized
* with higher score than threshold, but
*
* - _DISCARD action discards any other rules (will return all relevant _DISCARD actions)
* - only the TOP ranked "interaction" action will be returned
* - actions will come in THE SAME order, so the "setState" will be applied in the same order
*
*
* @param {LLMMessage|string} result
* @param {PreprocessedRule[]} rules
* @param {IStateRequest} req
* @param {Responder} res
* @returns {Promise<EvaluationResult>}
*/
async evaluateResultWithRules (result, rules, req, res) {
const text = typeof result === 'string' ? result : result.content;
const nlpResult = await this._ai.queryModel(text, req);
const state = stateData(req, res);
let topRankedAction = null;
let topActionScore = 0;
let discard = false;
const setState = {};
const sAct = Object.values(LLM.EVALUATION_ACTIONS);
const results = rules
.filter((rule) => rule.condition(req, res))
.map((rule) => {
const matched = this._ai.matcher
.matchText(text, rule.rule, nlpResult, state);
if (!matched || matched.score < this._ai.threshold) {
return null;
}
if (rule.action === LLM.EVALUATION_ACTIONS.DISCARD) {
discard = true;
} else if (rule.action && topActionScore < matched.score) {
topRankedAction = rule.action;
topActionScore = matched.score;
}
return {
...rule,
score: matched.score
};
})
.filter((rule) => rule !== null
&& (!discard || rule.action === LLM.EVALUATION_ACTIONS.DISCARD)
&& (!rule.action || rule.action === topRankedAction || sAct.includes(rule.action)));
results.forEach((rule) => {
Object.assign(setState, getSetState(rule.setState, req, res, setState));
});
return {
setState,
results,
discard,
action: discard ? null : topRankedAction
};
}
}
module.exports = LLM;