Mirrored from GitHub
github.com/roostorg/coop
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 };