import { type ScalarType, type TaggedScalar } from '@roostorg/coop-types'; import _Ajv, { type JSONSchemaType } from 'ajv-draft-04'; import _ from 'lodash'; import { type ReadonlyDeep } from 'type-fest'; import { extractContentValueOrValues } from '../../condition_evaluator/leafCondition.js'; import { b64UrlDecode, b64UrlEncode, jsonParse, jsonStringifyUnstable, type B64UrlOf, type JsonOf, } from '../../utils/encoding.js'; import { makeDerivedFieldPermanentError, type CoopError, } from '../../utils/errors.js'; import { everyAsync } from '../../utils/fp-helpers.js'; import { assertUnreachable } from '../../utils/misc.js'; import { type NonEmptyArray } from '../../utils/typescript-types.js'; import { type ItemSubmission } from '../itemProcessingService/makeItemSubmission.js'; import { CoopInput, type TaggedItemData, } from '../moderationConfigService/index.js'; import { type TransientRunSignalWithCache } from '../orgAwareSignalExecutionService/signalExecutionService.js'; import { isSignalErrorResult, SignalType, type SignalId, type SignalInputType, type SignalOutputType, type SignalsService, } from '../signalsService/index.js'; const Ajv = _Ajv as unknown as typeof _Ajv.default; const { sum } = _; // A bit of data that defines how to create a derived field, and that also // serves as a unique identity for the field within a given content type. // (Ie, no two derived fields on the same content type will have the same spec.) // // NB: this data is exposed to end users through the API -- both directly and // through the stringified version of the spec used to request the associated // derived field's comuputed value -- which is why it (partially) duplicates, // rather than references, the ConditionInput types in its `source` definition: // if we later modify ConditionInput's definition for internal use, we don't // want to inadvertently change the public API; having a type mismatch at that // point will make TS alert us of this risk, while letting us use the // Condition-input processing helper fns against these specs for now. export type DerivedFieldSpec = { source: | { type: 'FULL_ITEM' } | { type: 'CONTENT_FIELD'; name: string; contentTypeId: string } | { type: 'CONTENT_COOP_INPUT'; name: CoopInput }; derivationType: DerivedFieldType; }; export type DerivedFieldSpecSource = DerivedFieldSpec['source']; export type DerivedFieldType = keyof typeof derivedFieldRecipes; export type DeriveFieldOperation = RunSignalOperation; export enum DerivedFieldOperationType { RUN_SIGNAL = 'RUN_SIGNAL', } export type RunSignalOperation = { type: DerivedFieldOperationType.RUN_SIGNAL; args: { id: SignalId; subcategory?: string }; }; // For each DerivedFieldType that we can reference in a DerivedFieldSpec, we map // it below into a more detailed set of steps (a 'recipe') for how to actually // compute the derived value. Unlike the DerivedFieldSpec, recipes are wholly // internal implementation details, not exposed anywhere to end users (via REST // or GraphQL). // // These recipes are defined as data (an array of sequential operation objects), // not as functions, so that the recipe can be analyzed -- e.g., we can // calculate the total cost of deriving the field by finding the 'RUN_SIGNAL' // steps and summing the cost of each associated signal. // // Currently, the only operation type is calling a signal, but we know that // these 'recipes' are gonna have to support other kind of steps for derived // fields that use multiple input fields and aggregate the results, like the // 'All text (including text extracted from images and videos)' field. export const derivedFieldRecipes = { VIDEO_TRANSCRIPTION: [ { type: DerivedFieldOperationType.RUN_SIGNAL, args: { id: { type: SignalType.OPEN_AI_WHISPER_TRANSCRIPTION } }, }, ] satisfies DerivedFieldRecipe as DerivedFieldRecipe, }; export type DerivedFieldRecipe = ReadonlyDeep< NonEmptyArray >; export const derivedFieldTypes = Object.keys( derivedFieldRecipes, ) as DerivedFieldType[]; export async function getFieldDerivationCost( getSignalCost: (it: SignalId) => Promise, spec: DerivedFieldSpec, ) { return sum( await Promise.all( derivedFieldRecipes[spec.derivationType] // This filter is currently unnecessary, but it's here to make sure if // we ever add a new value to the DerivedFieldOperationType enum, we'll be // properly filtering these derived field recipes // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition .filter((it) => it.type === DerivedFieldOperationType.RUN_SIGNAL) .map(async (it) => getSignalCost(it.args.id)), ), ); } /** * Returns what ScalarTypes (and possibly the full content object) are eligible * to be fed in as inputs to derived fields of the given derivationType. * * For now, its assumed that, if some ScalarType, x, is a valid input, then an * array/map ContainerType that has x as its valueScalarType is valid as well. */ export async function getDerivedFieldInputTypes( getSignal: SignalsService['getSignalOrThrow'], derivationType: DerivedFieldType, orgId: string, ): Promise { const [recipeFirstStep] = derivedFieldRecipes[derivationType]; // NB: this intentionally doesn't check the step's type, so that we'll get // type errors if we define new step types besides RUN_SIGNAL. const signal = await getSignal({ orgId, signalId: recipeFirstStep.args.id }); return signal.eligibleInputs; } /** * Returns what ScalarTypes (and possibly the full content object) are eligible * to be returned as values for derived fields of the given derivationType. * * For now, its assumed that, if some ScalarType, x, is a valid input, then an * array/map ContainerType that has x as its valueScalarType is valid as well. */ export async function getDerivedFieldOutputType( getSignal: SignalsService['getSignalOrThrow'], derivationType: DerivedFieldType, orgId: string, ): Promise { const recipeLastStep = derivedFieldRecipes[derivationType].at(-1)!; // NB: this intentionally doesn't check the step's type, so that we'll get type // errors if we define new step types besides RUN_SIGNAL. const signal = await getSignal({ orgId, signalId: recipeLastStep.args.id }); return signal.outputType; } /** * Returns whether the derived field is enabled for the given org. For derived * fields whose recipe involves running a signal, we check if that signal is * enabled. */ export async function getDerivedFieldIsEnabled( getSignalDisabled: SignalsService['getSignalDisabledForOrg'], derivationType: DerivedFieldType, orgId: string, ) { const runSignalSteps = derivedFieldRecipes[derivationType].filter( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition (it) => it.type === DerivedFieldOperationType.RUN_SIGNAL, ); const allSignalsEnabled = await everyAsync(runSignalSteps, async (step) => { const res = await getSignalDisabled({ orgId, signalId: step.args.id }); return !res ? false : !res.disabled; }); return allSignalsEnabled; } // We allow users to request specific derived fields from the API. // To make this possible, we have to be able to convert a DerivedFieldSpec to // and from a string, which will be used by the consumer to request their field // of interest. The easiest way, of course -- both now and if/when we want to // extend the DerivedFieldSpec format -- would be to just JSON.stringify() the // spec, and probably base64url encode the result, so that callers don't run // into url encoding issues. The one potential issue with that is that it's not // very user-friendly. Then again, almost no format we could come up with here // would be simple enough to allow user devs to guess what value they should // use, and even hand-writing the value following some documented syntax would // be tricky (esp. w/o encoding edge cases), so I think a simple, standard // encodings of JSON might actually be the best. export function serializeDerivedFieldSpec(spec: DerivedFieldSpec) { // TODO: this really should use a stable serialization -- i.e., one where the // order in which we add the keys to the `spec` object doesn't change the // serialization result. However, we didn't do that from the beginning, and // normalizing the key order in the stringified result now would likely break // existing users, who are depending on the current strings. So, we need // to find a migration path for those, and switch to `true` for the future. return b64UrlEncode(jsonStringifyUnstable(spec)); } const derivedFieldSpecSchema: JSONSchemaType = { type: 'object', properties: { source: { type: 'object', required: ['type'], oneOf: [ { properties: { type: { type: 'string', const: 'FULL_ITEM' }, }, required: ['type'], additionalProperties: false, }, { properties: { type: { type: 'string', const: 'CONTENT_FIELD' }, name: { type: 'string' }, contentTypeId: { type: 'string' }, }, required: ['type', 'name', 'contentTypeId'], additionalProperties: false, }, { properties: { type: { type: 'string', const: 'CONTENT_COOP_INPUT' }, name: { type: 'string', enum: Object.values(CoopInput) }, }, required: ['type', 'name'], additionalProperties: false, }, ], }, derivationType: { type: 'string', enum: derivedFieldTypes }, }, required: ['source', 'derivationType'], additionalProperties: false, }; const ajv = new Ajv(); const validateDerivedFieldSpec = ajv.compile(derivedFieldSpecSchema); export function parseDerivedFieldSpec( spec: B64UrlOf>, ): DerivedFieldSpec { const parsedResult = jsonParse(b64UrlDecode(spec)); if (validateDerivedFieldSpec(parsedResult)) { return parsedResult; } else { throw new Error(`Invalid derived field spec`, { cause: new AggregateError(validateDerivedFieldSpec.errors ?? []), }); } } export type DerivedFieldValue = | TaggedScalar | TaggedScalar[] | TaggedItemData | undefined // if we weren't able to derive the field's value for some permanent reason; // likely a permanent failure in one of the used derivation signals. | CoopError<'DerivedFieldPermanentError'>; /** * This computes and returns the value of a derived field. * * @param runSignal A function to run a signal, which is needed b/c a derived * field will usually use a signal result as part of creating its final value. * @param contextOrgId The org id for which the derived field is being computed. * This needs to be passed as part of running the relevant signals (e.g., for * looking up the org's API keys for third-party-based signals.) * @param submission The content from which to derive the field's value. * @param derivedFieldSpec The spec defining what field to derive. * (See {@link DerivedFieldSpec}). */ export async function getDerivedFieldValue( runSignal: TransientRunSignalWithCache, contextOrgId: string, itemSubmission: ItemSubmission, derivedFieldSpec: DerivedFieldSpec, ): Promise { const signalInput = extractContentValueOrValues( itemSubmission, derivedFieldSpec.source, ); // NB: it's fairly expected to have some cases where the derived field simply // doesn't exist on the submission, which is what undefined represents. A // typical case would be if the derived field's spec uses a field from content // type A as the derived field's input, but this derived field is referenced // in a condition in a rule that runs on content types A and B. In that case, // content submissions of content type B will have undefined for the derived field. if (signalInput === undefined) { return undefined; } async function transformWithSignal( signal: SignalId, signalArgs: { subcategory?: string | null }, value: TaggedScalar | TaggedItemData, ): Promise> { // NB: no matchingValues support here yet, b/c we don't need it, // and b/c the matchingValues aren't stored on the ConditionSignalInfo // type, which we're reusing for the signal's args. const signalResult = await runSignal({ signal, value, orgId: contextOrgId, userId: itemSubmission.creator?.id, subcategory: signalArgs.subcategory ?? undefined, }); if (isSignalErrorResult(signalResult)) { throw signalResult.score; } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return { type: signalResult.outputType.scalarType, value: signalResult.score, } as TaggedScalar; } return derivedFieldRecipes[derivedFieldSpec.derivationType] .reduce(async (derivedResPromise, recipeOperation) => { // get value(s) as they've been generated so far. const valueOrValues = await derivedResPromise; // The DerivedFieldOperationType enum has only one variant // (RUN_SIGNAL) today, so the case label and the discriminant are // trivially equal and trip no-unnecessary-condition. The switch is // intentional: when a new variant is added, the existing case stops // being exhaustive and the "default: assertUnreachable(...)" branch // surfaces the gap at compile time. Disabling the condition rule // (only) preserves that safety net. switch (recipeOperation.type) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- see comment above case DerivedFieldOperationType.RUN_SIGNAL: { const transformValue = transformWithSignal.bind( null, recipeOperation.args.id, recipeOperation.args, ); return Array.isArray(valueOrValues) ? Promise.all(valueOrValues.map(async (v) => transformValue(v))) : transformValue(valueOrValues); } default: assertUnreachable(recipeOperation.type); } }, Promise.resolve(signalInput)) .catch((error) => { return makeDerivedFieldPermanentError('Failed to derive field value.', { cause: error, shouldErrorSpan: true, }); }); }