Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

coop / server / rule_engine / RuleEvaluator.ts
8.1 kB 194 lines
1import _ from 'lodash'; 2import { type Opaque, type ReadonlyDeep } from 'type-fest'; 3 4import { outcomeToNullableBool } from '../condition_evaluator/condition.js'; 5import { getConditionSetResults } from '../condition_evaluator/conditionSet.js'; 6import makeGetDerivedFieldValueWithCache from '../condition_evaluator/getDerivedFieldValue.js'; 7import { type Dependencies } from '../iocContainer/index.js'; 8import { inject } from '../iocContainer/utils.js'; 9import type { 10 ActionExecutionSourceType, 11 RuleExecutionSourceType, 12} from '../services/analyticsLoggers/index.js'; 13import { 14 type DerivedFieldSpec, 15 type DerivedFieldValue, 16} from '../services/derivedFieldsService/index.js'; 17import { type ItemSubmission } from '../services/itemProcessingService/index.js'; 18import { 19 type ConditionSet, 20 type ConditionSetWithResult, 21 type ItemType, 22} from '../services/moderationConfigService/index.js'; 23import { type TransientRunSignalWithCache } from '../services/orgAwareSignalExecutionService/index.js'; 24import { type SignalId } from '../services/signalsService/index.js'; 25import { instantiateOpaqueType } from '../utils/typescript-types.js'; 26 27// A rule can be run on either a full submission, or on just the identifier of 28// an item (namely, in the case of user rules). 29export type RuleInput = 30 // Policies only present in reporting + routing rules; 31 // refers to the policy of the report. 32 | (ItemSubmission & { 33 policyIds?: string[]; 34 sourceType?: RuleExecutionSourceType | ActionExecutionSourceType; 35 }) 36 | ReadonlyDeep<{ 37 itemId: string; 38 itemType: Pick<ItemType, 'id' | 'kind' | 'name'>; 39 }>; 40 41export function isFullSubmission(input: RuleInput): input is ItemSubmission { 42 return 'data' in input && 'submissionId' in input && Boolean(input.data); 43} 44 45export function getUserFromRuleInput(it: RuleInput) { 46 return isFullSubmission(it) 47 ? it.creator 48 : it.itemType.kind === 'USER' 49 ? { id: it.itemId, typeId: it.itemType.id } 50 : undefined; 51} 52 53type RuleEvaluationContextImpl = Readonly<{ 54 org: { id: string /* TODO: might add api keys or api key ids here too */ }; 55 input: RuleInput; 56 runSignal: TransientRunSignalWithCache; 57 getSignalCost: (id: SignalId) => Promise<number>; 58 getDerivedFieldValue: (spec: DerivedFieldSpec) => Promise<DerivedFieldValue>; 59}>; 60 61export type RuleEvaluationContext = Opaque< 62 RuleEvaluationContextImpl, 63 RuleEvaluationContextImpl 64>; 65 66export type RuleExecutionResult = { 67 passed: boolean; 68 conditionResults: ConditionSetWithResult; 69}; 70 71/** 72 * This is the main Rule Engine class. It's responsible for running 73 * all of the user's Rules on a single piece of content sent to 74 * our API. 75 */ 76class RuleEvaluator { 77 constructor( 78 private readonly makeRunSignal: Dependencies['TransientRunSignalWithCacheFactory'], 79 private readonly signalsService: Dependencies['SignalsService'], 80 private readonly tracer: Dependencies['Tracer'], 81 ) {} 82 83 /** 84 * Evaluating a rule's conditionSet requires some arguments, like the content 85 * against which you want to evaluate the conditions (see RuleInput). 86 * 87 * However, in addition to these data arguments, running a rule also requires 88 * various "capabilities", e.g., the ability to evaluate a signal for some 89 * value (as required by one of the rule's conditions) and the ability to 90 * compute derived values from the content. 91 * 92 * For these "capabilities", we may want to retain some state between from one 93 * rule evaluation to the next, for the purpose of caching. E.g., if multiple 94 * conditions (in the same rule, or across rules) run the same signal against 95 * the same input, we'd like to be able to cache and reuse that result. 96 * Similarly, if multiple conditions reference the same derived field, we'd 97 * like to only have to compute its value once. 98 * 99 * The scope of this cache -- e.g., is it distributed, in a way all api 100 * servers can access? if not, and it just lives in one server's memory, does 101 * it persist across requests to that server (and expire on a TTL), or does it 102 * get created at the start of some request/unit of work and discarded at the 103 * end? -- is likely to change over time, as we evolve the system. 104 * 105 * For now, though, we have an explicit object that holds this cached state, 106 * and which is expected to be created at the start of some unit of work (like 107 * a rule set execution) and then discarded shortly thereafter. That object is 108 * a "rule execution context", and it's what's returned here. Because, again, 109 * this concept may change over time, I'm treating it as a detail of the 110 * RuleEngine, by having the RuleEngine be the only code (in the method below) 111 * that can create the context (using an opaque type), and the only code that 112 * will consume it. 113 * 114 * As implied by this method's arguments, a context cannot be reused across 115 * different RuleInputs/different item submissions, in part to help enforce 116 * that these objects be short-lived, but also because the RuleInput data 117 * contributes to the cache keys used within the evaluation context (e.g., for 118 * `getDerivedFieldValue`). 119 */ 120 makeRuleExecutionContext(args: { 121 orgId: string; 122 input: RuleInput; 123 }): RuleEvaluationContext { 124 const { orgId, input } = args; 125 const runSignal = this.makeRunSignal(); 126 const getDerivedFieldValue = makeGetDerivedFieldValueWithCache( 127 runSignal, 128 orgId, 129 ); 130 131 return instantiateOpaqueType<RuleEvaluationContext>({ 132 org: { id: orgId }, 133 input, 134 runSignal, 135 getSignalCost: async (signalId: SignalId) => 136 this.signalsService 137 .getSignalOrThrow({ orgId, signalId }) 138 .then((s) => s.getCost()) 139 .catch(() => Infinity), 140 // If the item data is missing, as it will be in user rule 141 // RuleEvaluationContexts, then we can't extract a derived field value 142 // (at least for now, since we don't have a notion of derived fields 143 // from from item ids), so we return undefined. 144 getDerivedFieldValue: isFullSubmission(input) 145 ? async (spec: DerivedFieldSpec) => getDerivedFieldValue(input, spec) 146 : async (_spec: DerivedFieldSpec) => undefined, 147 }); 148 } 149 150 /** 151 * This function runs a piece of content through a single Rule and returns the 152 * results. 153 * 154 * Note that this function _does not_ perform any of the side effects that 155 * usually attend a rule execution, including (even) logging that execution to 156 * the data warehouse. For that reason, this function is private, and should only be 157 * called as an implementation detail of the RuleEngine. 158 * 159 * @param ruleConditions - The conditions that logically define the rule 160 * (i.e., determine whether the it passes, independent of metadata about it 161 * like its name etc). We don't need full Rule instance from our db, which 162 * is a fact we might leverage later (e.g., if we wanna cache the results of 163 * this function, we'd do it by ruleConditions, rather than rule id + version, 164 * as the conditions might not change between versions). 165 * @param context - the context needed to run the rule, including, most 166 * notably, the user-generated content to run the rule against and/or a user 167 * id that can be selected as an input to (future) user-scoring signals. 168 * @return Whether the content passed the rule's conditions, and details 169 * about which subconditions did/didn't match. 170 */ 171 public async runRule( 172 ruleConditions: ReadonlyDeep<ConditionSet>, 173 context: RuleEvaluationContext, 174 ): Promise<RuleExecutionResult> { 175 const results = await getConditionSetResults( 176 ruleConditions, 177 context, 178 this.tracer, 179 ); 180 181 return { 182 // If the rule outcome was null (e.g., if some critical condition errored), 183 // coalesce to false to treat the rule as though it didn't pass 184 passed: outcomeToNullableBool(results.result.outcome) ?? false, 185 conditionResults: results, 186 }; 187 } 188} 189 190export default inject( 191 ['TransientRunSignalWithCacheFactory', 'SignalsService', 'Tracer'], 192 RuleEvaluator, 193); 194export { type RuleEvaluator };