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