From 808e865215bd183a979787a42cd2bf7bc512800b Mon Sep 17 00:00:00 2001 From: Mark Reitblatt Date: Wed, 17 Jun 2026 11:00:19 -0700 Subject: [PATCH 1/7] [Feature] Support self-hosted CoPE model within Zentropi integration (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ability to run a vLLM-served CoPE model as an alternative to the Zentropi hosted API, without adding a new signal type. Users choose between hosted mode (API key + labeler_version_id) and self-hosted mode (base URL, model name, and format) in the Zentropi integration settings. Two self-hosted formats are supported: - cope: vLLM /v1/completions with the CoPE fixed-prompt template - openai_chat: configurable chat completions with {criteria}/{content} templates Score extraction uses logprobs in both cases, producing 0–1 output matching the Zentropi hosted scale. Shared openaiCompatibleUtils module is factored for reuse in future self-hosted integrations. Co-Authored-By: Claude Sonnet 4.6 --- client/src/graphql/generated.ts | 40 ++- ...IntegrationConfigApiCredentialsSection.tsx | 241 ++++++++++++++---- .../integrations/IntegrationConfigForm.tsx | 84 +++--- ...00.add_self_hosted_to_zentropi_configs.sql | 13 + server/graphql/datasources/IntegrationApi.ts | 11 +- server/graphql/generated.ts | 58 ++++- server/graphql/modules/integration.ts | 22 +- server/services/signalAuthService/dbTypes.ts | 8 +- .../signalAuthService/signalAuthService.ts | 121 +++++++-- .../helpers/instantiateBuiltInSignals.ts | 2 + .../helpers/makeCachedFetchers.ts | 4 + .../openaiCompatibleUtils.ts | 171 +++++++++++++ .../zentropi/ZentropiLabelerSignal.test.ts | 19 +- .../zentropi/ZentropiLabelerSignal.ts | 15 +- .../zentropi/zentropiUtils.test.ts | 22 +- .../zentropi/zentropiUtils.ts | 42 ++- 16 files changed, 748 insertions(+), 125 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.06.17T00.00.00.add_self_hosted_to_zentropi_configs.sql create mode 100644 server/services/signalsService/signals/third_party_signals/openai_compatible/openaiCompatibleUtils.ts diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 78d8dac86..37151d0ef 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -5177,13 +5177,15 @@ export type GQLZentropiIntegrationApiCredential = { readonly __typename: 'ZentropiIntegrationApiCredential'; readonly apiKey: Scalars['String']['output']; readonly labelerVersions: ReadonlyArray; + readonly selfHosted?: Maybe; }; export type GQLZentropiIntegrationApiCredentialInput = { - readonly apiKey: Scalars['String']['input']; + readonly apiKey?: InputMaybe; readonly labelerVersions?: InputMaybe< ReadonlyArray >; + readonly selfHosted?: InputMaybe; }; export type GQLZentropiLabelerVersion = { @@ -5197,6 +5199,25 @@ export type GQLZentropiLabelerVersionInput = { readonly label: Scalars['String']['input']; }; +export type GQLZentropiSelfHostedConfig = { + readonly __typename: 'ZentropiSelfHostedConfig'; + readonly apiKey?: Maybe; + readonly baseUrl: Scalars['String']['output']; + readonly format: Scalars['String']['output']; + readonly model: Scalars['String']['output']; + readonly systemPromptTemplate?: Maybe; + readonly userMessageTemplate?: Maybe; +}; + +export type GQLZentropiSelfHostedConfigInput = { + readonly apiKey?: InputMaybe; + readonly baseUrl: Scalars['String']['input']; + readonly format: Scalars['String']['input']; + readonly model: Scalars['String']['input']; + readonly systemPromptTemplate?: InputMaybe; + readonly userMessageTemplate?: InputMaybe; +}; + export type GQLApiAuthQueryVariables = Exact<{ [key: string]: never }>; export type GQLApiAuthQuery = { @@ -6414,6 +6435,15 @@ export type GQLIntegrationConfigQuery = { readonly id: string; readonly label: string; }>; + readonly selfHosted?: { + readonly __typename: 'ZentropiSelfHostedConfig'; + readonly format: string; + readonly baseUrl: string; + readonly model: string; + readonly apiKey?: string | null; + readonly systemPromptTemplate?: string | null; + readonly userMessageTemplate?: string | null; + } | null; }; } | null; } @@ -29892,6 +29922,14 @@ export const GQLIntegrationConfigDocument = gql` id label } + selfHosted { + format + baseUrl + model + apiKey + systemPromptTemplate + userMessageTemplate + } } ... on PluginIntegrationApiCredential { credential diff --git a/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx b/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx index c4441b39c..35ded72d3 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx @@ -1,4 +1,4 @@ -import { Button, Input } from 'antd'; +import { Button, Input, Select } from 'antd'; import { Plus, Trash2 } from 'lucide-react'; import { @@ -6,6 +6,7 @@ import { GQLIntegrationApiCredential, GQLOpenAiIntegrationApiCredential, GQLZentropiIntegrationApiCredential, + GQLZentropiSelfHostedConfig, } from '../../../graphql/generated'; export default function IntegrationConfigApiCredentialsSection(props: { @@ -59,6 +60,8 @@ export default function IntegrationConfigApiCredentialsSection(props: { apiCredential: GQLZentropiIntegrationApiCredential, ) => { const labelerVersions = apiCredential.labelerVersions ?? []; + const selfHosted = apiCredential.selfHosted ?? null; + const isHosted = selfHosted == null; const updateLabelerVersion = ( index: number, @@ -88,61 +91,203 @@ export default function IntegrationConfigApiCredentialsSection(props: { }); }; + const updateSelfHosted = (patch: Partial) => { + setApiCredential({ + ...apiCredential, + selfHosted: { + __typename: 'ZentropiSelfHostedConfig' as const, + format: 'cope', + baseUrl: '', + model: '', + ...selfHosted, + ...patch, + }, + }); + }; + + const switchToHosted = () => { + setApiCredential({ ...apiCredential, selfHosted: null }); + }; + + const switchToSelfHosted = () => { + setApiCredential({ + ...apiCredential, + selfHosted: { + __typename: 'ZentropiSelfHostedConfig' as const, + format: 'cope', + baseUrl: '', + model: '', + }, + }); + }; + return (
-
API Key
- - setApiCredential({ - ...apiCredential, - apiKey: event.target.value, - }) - } +
Mode
+ + setApiCredential({ + ...apiCredential, + apiKey: event.target.value, + }) + } + /> +
+
+
Labeler Versions
+ {labelerVersions.map((version, index) => ( +
+ + updateLabelerVersion(index, 'id', event.target.value) + } + className="flex-1" + /> + + updateLabelerVersion(index, 'label', event.target.value) + } + className="flex-1" + /> +
+ ))} + +
+ + ) : ( + <> +
+
+ Base URL * +
- updateLabelerVersion(index, 'id', event.target.value) + updateSelfHosted({ baseUrl: event.target.value }) } - className="flex-1" /> +
+
+
+ Model * +
- updateLabelerVersion(index, 'label', event.target.value) + updateSelfHosted({ model: event.target.value }) } - className="flex-1" /> -
+
+
Format
+ + updateSelfHosted({ + apiKey: event.target.value || undefined, + }) + } + /> +
+ {selfHosted.format === 'openai_chat' && ( + <> +
+
+ System Prompt Template + + (use {'{criteria}'} for policy text) + +
+ + updateSelfHosted({ + systemPromptTemplate: event.target.value || undefined, + }) + } + /> +
+
+
+ User Message Template + + (use {'{content}'} for content text) + +
+ + updateSelfHosted({ + userMessageTemplate: event.target.value || undefined, + }) + } + /> +
+ + )} + + )}
); }; @@ -151,9 +296,10 @@ export default function IntegrationConfigApiCredentialsSection(props: { truePercentage: 'True percentage (0–100)', }; - const renderPluginCredential = ( - pluginCredential: { __typename: 'PluginIntegrationApiCredential'; credential: Record }, - ) => { + const renderPluginCredential = (pluginCredential: { + __typename: 'PluginIntegrationApiCredential'; + credential: Record; + }) => { const credential = pluginCredential.credential ?? {}; const entries = Object.entries(credential).filter( ([key]) => key !== 'name', @@ -166,16 +312,15 @@ export default function IntegrationConfigApiCredentialsSection(props: {
{fieldsToShow.map(([key, value]) => (
-
- {PLUGIN_FIELD_LABELS[key] ?? key} -
+
{PLUGIN_FIELD_LABELS[key] ?? key}
{ const next = { ...credential, [key]: event.target.value }; setApiCredential({ __typename: 'PluginIntegrationApiCredential', - credential: next as import('../../../graphql/generated').Scalars['JSONObject'], + credential: + next as import('../../../graphql/generated').Scalars['JSONObject'], }); }} /> diff --git a/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx b/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx index 2ec4fc7e9..94030e0bb 100644 --- a/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx +++ b/client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx @@ -49,7 +49,9 @@ gql` } } - mutation SetPluginIntegrationConfig($input: SetPluginIntegrationConfigInput!) { + mutation SetPluginIntegrationConfig( + $input: SetPluginIntegrationConfigInput! + ) { setPluginIntegrationConfig(input: $input) { ... on SetIntegrationConfigSuccessResponse { config { @@ -112,6 +114,14 @@ gql` id label } + selfHosted { + format + baseUrl + model + apiKey + systemPromptTemplate + userMessageTemplate + } } ... on PluginIntegrationApiCredential { credential @@ -136,9 +146,7 @@ gql` * This function returns an empty API credential config (type is * IntegrationConfigApiCredential), so the UI can display the proper empty inputs. */ -export function getNewEmptyApiKey( - name: string, -): GQLIntegrationApiCredential { +export function getNewEmptyApiKey(name: string): GQLIntegrationApiCredential { switch (name) { case 'GOOGLE_CONTENT_SAFETY_API': { return { @@ -154,6 +162,7 @@ export function getNewEmptyApiKey( __typename: 'ZentropiIntegrationApiCredential', apiKey: '', labelerVersions: [], + selfHosted: null, }; } default: { @@ -258,8 +267,7 @@ export default function IntegrationConfigForm() { response?.__typename === 'IntegrationConfigSuccessResult' ? response.config : undefined; - const formattedName = - apiConfig?.title ?? integrationName.replace(/_/g, ' '); + const formattedName = apiConfig?.title ?? integrationName.replace(/_/g, ' '); const logo = apiConfig ? (apiConfig.logoUrl ?? INTEGRATION_LOGO_FALLBACKS[apiConfig.name]?.logo ?? @@ -290,7 +298,7 @@ export default function IntegrationConfigForm() { 'googleContentSafetyApi' in mappedApiCredential && !( mappedApiCredential[ - 'googleContentSafetyApi' + 'googleContentSafetyApi' ] as GQLGoogleContentSafetyApiIntegrationApiCredential ).apiKey ) { @@ -305,15 +313,15 @@ export default function IntegrationConfigForm() { return 'Please input the OpenAI API key'; } - if ( - 'zentropi' in mappedApiCredential && - !( - mappedApiCredential[ - 'zentropi' - ] as GQLZentropiIntegrationApiCredential - ).apiKey - ) { - return 'Please input the Zentropi API key'; + if ('zentropi' in mappedApiCredential) { + const zentropiCred = mappedApiCredential[ + 'zentropi' + ] as GQLZentropiIntegrationApiCredential; + const hasSelfHosted = + zentropiCred.selfHosted?.baseUrl && zentropiCred.selfHosted?.model; + if (!zentropiCred.apiKey && !hasSelfHosted) { + return 'Please input either a Zentropi API key or a self-hosted model URL and model name'; + } } return undefined; @@ -334,7 +342,7 @@ export default function IntegrationConfigForm() { if (isPluginIntegration) { const cred = apiCredential.__typename === 'PluginIntegrationApiCredential' - ? apiCredential.credential ?? {} + ? (apiCredential.credential ?? {}) : {}; await setPluginConfig({ variables: { @@ -361,15 +369,15 @@ export default function IntegrationConfigForm() { const [modalTitle, modalBody, modalButtonText] = mutationError == null ? [ - `${formattedName} Config Saved`, - `Your ${formattedName} Config was successfully saved!`, - 'Done', - ] + `${formattedName} Config Saved`, + `Your ${formattedName} Config was successfully saved!`, + 'Done', + ] : [ - `Error Saving ${formattedName} Config`, - `We encountered an error trying to save your ${formattedName} Config. Please try again.`, - 'OK', - ]; + `Error Saving ${formattedName} Config`, + `We encountered an error trying to save your ${formattedName} Config. Please try again.`, + 'OK', + ]; const onHideModal = () => { hideModal(); @@ -403,11 +411,11 @@ export default function IntegrationConfigForm() { case 'GOOGLE_CONTENT_SAFETY_API': return ( <> - The Content Safety API is an AI classifier which issues a Child - Safety prioritization recommendation on content sent to it. Content Safety API users - must conduct their own manual review in order to determine whether to take - action on the content, and comply with applicable local reporting - laws. Apply for API keys{' '} + The Content Safety API is an AI classifier which issues a Child + Safety prioritization recommendation on content sent to it. Content + Safety API users must conduct their own manual review in order to + determine whether to take action on the content, and comply with + applicable local reporting laws. Apply for API keys{' '} here - - {' '} + {' '} and mention in your application that you are using the Coop moderation tool. Upon reviewing your application, Google will be - back in touch shortly to take the application forward if you qualify. + back in touch shortly to take the application forward if you + qualify. ); case 'OPEN_AI': @@ -441,11 +449,7 @@ export default function IntegrationConfigForm() {
- +
{`${formattedName} Integration`}
@@ -473,7 +477,9 @@ export default function IntegrationConfigForm() { Learn more about how to read model cards )} -
Configuration
+
+ Configuration +
Configure your integration settings below.
diff --git a/db/src/scripts/api-server-pg/2026.06.17T00.00.00.add_self_hosted_to_zentropi_configs.sql b/db/src/scripts/api-server-pg/2026.06.17T00.00.00.add_self_hosted_to_zentropi_configs.sql new file mode 100644 index 000000000..1fe87562c --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.06.17T00.00.00.add_self_hosted_to_zentropi_configs.sql @@ -0,0 +1,13 @@ +-- Allow orgs that only use self-hosted CoPE to omit the hosted API key. +ALTER TABLE signal_auth_service.zentropi_configs + ALTER COLUMN api_key DROP NOT NULL; + +-- Self-hosted model configuration columns. All nullable; presence of +-- self_hosted_base_url indicates self-hosted mode is configured. +ALTER TABLE signal_auth_service.zentropi_configs + ADD COLUMN IF NOT EXISTS self_hosted_base_url TEXT, + ADD COLUMN IF NOT EXISTS self_hosted_model TEXT, + ADD COLUMN IF NOT EXISTS self_hosted_api_key TEXT, + ADD COLUMN IF NOT EXISTS self_hosted_format TEXT, + ADD COLUMN IF NOT EXISTS self_hosted_system_prompt_template TEXT, + ADD COLUMN IF NOT EXISTS self_hosted_user_message_template TEXT; diff --git a/server/graphql/datasources/IntegrationApi.ts b/server/graphql/datasources/IntegrationApi.ts index c1adae030..aaf7d176b 100644 --- a/server/graphql/datasources/IntegrationApi.ts +++ b/server/graphql/datasources/IntegrationApi.ts @@ -1,18 +1,19 @@ - import { inject, type Dependencies } from '../../iocContainer/index.js'; + import '../../services/signalAuthService/index.js'; + +import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; import { Integration } from '../../services/signalsService/index.js'; import { CoopError, ErrorType, type ErrorInstanceData, } from '../../utils/errors.js'; -import { getIntegrationRegistry } from '../../services/integrationRegistry/index.js'; +import { type GQLSetIntegrationConfigInput } from '../generated.js'; import type { IntegrationManifestEntry, ModelCard, } from './integrationManifests.js'; -import { type GQLSetIntegrationConfigInput } from '../generated.js'; export type TIntegrationConfigWithMetadata = Readonly<{ name: string; @@ -64,8 +65,7 @@ function mergeManifest( class IntegrationAPI { constructor( private readonly signalAuthService: Dependencies['SignalAuthService'], - ) { - } + ) {} async setConfig( params: GQLSetIntegrationConfigInput, @@ -93,6 +93,7 @@ class IntegrationAPI { { apiKey: apiCredential.zentropi.apiKey, labelerVersions: [...(apiCredential.zentropi.labelerVersions ?? [])], + selfHosted: apiCredential.zentropi.selfHosted ?? undefined, }, orgId, ); diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 6a24080bf..cb04e4c3b 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -5245,13 +5245,15 @@ export type GQLZentropiIntegrationApiCredential = { readonly __typename?: 'ZentropiIntegrationApiCredential'; readonly apiKey: Scalars['String']['output']; readonly labelerVersions: ReadonlyArray; + readonly selfHosted?: Maybe; }; export type GQLZentropiIntegrationApiCredentialInput = { - readonly apiKey: Scalars['String']['input']; + readonly apiKey?: InputMaybe; readonly labelerVersions?: InputMaybe< ReadonlyArray >; + readonly selfHosted?: InputMaybe; }; export type GQLZentropiLabelerVersion = { @@ -5265,6 +5267,25 @@ export type GQLZentropiLabelerVersionInput = { readonly label: Scalars['String']['input']; }; +export type GQLZentropiSelfHostedConfig = { + readonly __typename?: 'ZentropiSelfHostedConfig'; + readonly apiKey?: Maybe; + readonly baseUrl: Scalars['String']['output']; + readonly format: Scalars['String']['output']; + readonly model: Scalars['String']['output']; + readonly systemPromptTemplate?: Maybe; + readonly userMessageTemplate?: Maybe; +}; + +export type GQLZentropiSelfHostedConfigInput = { + readonly apiKey?: InputMaybe; + readonly baseUrl: Scalars['String']['input']; + readonly format: Scalars['String']['input']; + readonly model: Scalars['String']['input']; + readonly systemPromptTemplate?: InputMaybe; + readonly userMessageTemplate?: InputMaybe; +}; + export type ResolverTypeWrapper = Promise | T; export type ResolverWithResolve = { @@ -6559,6 +6580,8 @@ export type GQLResolversTypes = { ZentropiIntegrationApiCredentialInput: GQLZentropiIntegrationApiCredentialInput; ZentropiLabelerVersion: ResolverTypeWrapper; ZentropiLabelerVersionInput: GQLZentropiLabelerVersionInput; + ZentropiSelfHostedConfig: ResolverTypeWrapper; + ZentropiSelfHostedConfigInput: GQLZentropiSelfHostedConfigInput; }; /** Mapping between all available schema types and the resolvers parents */ @@ -7199,6 +7222,8 @@ export type GQLResolversParentTypes = { ZentropiIntegrationApiCredentialInput: GQLZentropiIntegrationApiCredentialInput; ZentropiLabelerVersion: GQLZentropiLabelerVersion; ZentropiLabelerVersionInput: GQLZentropiLabelerVersionInput; + ZentropiSelfHostedConfig: GQLZentropiSelfHostedConfig; + ZentropiSelfHostedConfigInput: GQLZentropiSelfHostedConfigInput; }; export type GQLPublicResolverDirectiveArgs = {}; @@ -14923,6 +14948,11 @@ export type GQLZentropiIntegrationApiCredentialResolvers< ParentType, ContextType >; + selfHosted?: Resolver< + Maybe, + ParentType, + ContextType + >; __isTypeOf?: IsTypeOfResolverFn; }; @@ -14935,6 +14965,31 @@ export type GQLZentropiLabelerVersionResolvers< label?: Resolver; }; +export type GQLZentropiSelfHostedConfigResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['ZentropiSelfHostedConfig'] = + GQLResolversParentTypes['ZentropiSelfHostedConfig'], +> = { + apiKey?: Resolver< + Maybe, + ParentType, + ContextType + >; + baseUrl?: Resolver; + format?: Resolver; + model?: Resolver; + systemPromptTemplate?: Resolver< + Maybe, + ParentType, + ContextType + >; + userMessageTemplate?: Resolver< + Maybe, + ParentType, + ContextType + >; +}; + export type GQLResolvers = { AcceptAppealDecisionComponent?: GQLAcceptAppealDecisionComponentResolvers; Action?: GQLActionResolvers; @@ -15267,6 +15322,7 @@ export type GQLResolvers = { WindowConfiguration?: GQLWindowConfigurationResolvers; ZentropiIntegrationApiCredential?: GQLZentropiIntegrationApiCredentialResolvers; ZentropiLabelerVersion?: GQLZentropiLabelerVersionResolvers; + ZentropiSelfHostedConfig?: GQLZentropiSelfHostedConfigResolvers; }; export type GQLDirectiveResolvers = { diff --git a/server/graphql/modules/integration.ts b/server/graphql/modules/integration.ts index 844d0fa91..3c5d0a864 100644 --- a/server/graphql/modules/integration.ts +++ b/server/graphql/modules/integration.ts @@ -35,9 +35,19 @@ const typeDefs = /* GraphQL */ ` label: String! } + type ZentropiSelfHostedConfig { + format: String! + baseUrl: String! + model: String! + apiKey: String + systemPromptTemplate: String + userMessageTemplate: String + } + type ZentropiIntegrationApiCredential { apiKey: String! labelerVersions: [ZentropiLabelerVersion!]! + selfHosted: ZentropiSelfHostedConfig } union IntegrationApiCredential = @@ -108,9 +118,19 @@ const typeDefs = /* GraphQL */ ` label: String! } + input ZentropiSelfHostedConfigInput { + format: String! + baseUrl: String! + model: String! + apiKey: String + systemPromptTemplate: String + userMessageTemplate: String + } + input ZentropiIntegrationApiCredentialInput { - apiKey: String! + apiKey: String labelerVersions: [ZentropiLabelerVersionInput!] + selfHosted: ZentropiSelfHostedConfigInput } input IntegrationApiCredentialInput { diff --git a/server/services/signalAuthService/dbTypes.ts b/server/services/signalAuthService/dbTypes.ts index e2946c0cd..52d2a1ada 100644 --- a/server/services/signalAuthService/dbTypes.ts +++ b/server/services/signalAuthService/dbTypes.ts @@ -29,12 +29,18 @@ export type SignalAuthServicePg = { }; 'signal_auth_service.zentropi_configs': { org_id: string; - api_key: string; + api_key: string | null; labeler_versions: ColumnType< string, string | undefined, string | undefined >; + self_hosted_base_url: string | null; + self_hosted_model: string | null; + self_hosted_api_key: string | null; + self_hosted_format: string | null; + self_hosted_system_prompt_template: string | null; + self_hosted_user_message_template: string | null; created_at: ColumnType; updated_at: ColumnType; }; diff --git a/server/services/signalAuthService/signalAuthService.ts b/server/services/signalAuthService/signalAuthService.ts index 81671fb15..fad99ab8c 100644 --- a/server/services/signalAuthService/signalAuthService.ts +++ b/server/services/signalAuthService/signalAuthService.ts @@ -25,9 +25,20 @@ export type Credentials = { export type GoogleContentSafetyCredential = { apiKey: string }; export type OpenAICredential = { apiKey: string }; export type ZentropiLabelerVersion = { id: string; label: string }; +export type ZentropiSelfHostedConfig = { + format: 'cope' | 'openai_chat'; + baseUrl: string; + model: string; + apiKey?: string; + /** Only used when format === 'openai_chat'. May contain {criteria} placeholder. */ + systemPromptTemplate?: string; + /** Only used when format === 'openai_chat'. May contain {content} placeholder. */ + userMessageTemplate?: string; +}; export type ZentropiCredential = { - apiKey: string; + apiKey?: string; labelerVersions?: ZentropiLabelerVersion[]; + selfHosted?: ZentropiSelfHostedConfig; }; export type ClarifaiApiCredential = { apiKey: NonEmptyString }; export type ClarifaiModelType = 'IMAGE' | 'TEXT'; @@ -135,7 +146,11 @@ class SignalAuthService { if (integrationId === Integration.ZENTROPI) { const c = await this.get(Integration.ZENTROPI, orgId); return c != null - ? { apiKey: c.apiKey, labelerVersions: c.labelerVersions } + ? { + apiKey: c.apiKey ?? '', + labelerVersions: c.labelerVersions, + selfHosted: c.selfHosted, + } : undefined; } const row = await this.pg @@ -170,12 +185,21 @@ class SignalAuthService { return { apiKey }; } if (integrationId === Integration.ZENTROPI) { - const apiKey = typeof config.apiKey === 'string' ? config.apiKey : ''; + const apiKey = + typeof config.apiKey === 'string' ? config.apiKey : undefined; const labelerVersions = Array.isArray(config.labelerVersions) ? (config.labelerVersions as ZentropiLabelerVersion[]) : []; - await this.set(Integration.ZENTROPI, orgId, { apiKey, labelerVersions }); - return { apiKey, labelerVersions }; + const selfHosted = + config.selfHosted != null && typeof config.selfHosted === 'object' + ? (config.selfHosted as ZentropiSelfHostedConfig) + : undefined; + await this.set(Integration.ZENTROPI, orgId, { + apiKey, + labelerVersions, + selfHosted, + }); + return { apiKey, labelerVersions, selfHosted }; } await this.pg .insertInto('signal_auth_service.integration_configs') @@ -267,49 +291,116 @@ function makeImplementations( get: async (orgId: string) => { const row = await pg .selectFrom('signal_auth_service.zentropi_configs') - .select(['api_key', 'labeler_versions']) + .select([ + 'api_key', + 'labeler_versions', + 'self_hosted_base_url', + 'self_hosted_model', + 'self_hosted_api_key', + 'self_hosted_format', + 'self_hosted_system_prompt_template', + 'self_hosted_user_message_template', + ]) .where('org_id', '=', orgId) .executeTakeFirst(); if (row == null) return undefined; const labelerVersions = row.labeler_versions; + const selfHosted: ZentropiSelfHostedConfig | undefined = + row.self_hosted_base_url != null && row.self_hosted_model != null + ? { + format: (row.self_hosted_format ?? 'cope') as + | 'cope' + | 'openai_chat', + baseUrl: row.self_hosted_base_url, + model: row.self_hosted_model, + apiKey: row.self_hosted_api_key ?? undefined, + systemPromptTemplate: + row.self_hosted_system_prompt_template ?? undefined, + userMessageTemplate: + row.self_hosted_user_message_template ?? undefined, + } + : undefined; return { - apiKey: row.api_key, + apiKey: row.api_key ?? undefined, labelerVersions: Array.isArray(labelerVersions) ? (labelerVersions as ZentropiLabelerVersion[]) : typeof labelerVersions === 'string' - ? jsonParse(labelerVersions as JsonOf) - : [], + ? jsonParse(labelerVersions as JsonOf) + : [], + selfHosted, }; }, set: async (orgId: string, credential: ZentropiCredential) => { const labelerVersionsJson = jsonStringify( credential.labelerVersions ?? [], ); + const sh = credential.selfHosted; const row = await pg .insertInto('signal_auth_service.zentropi_configs') .values([ { org_id: orgId, - api_key: credential.apiKey, + api_key: credential.apiKey ?? null, labeler_versions: labelerVersionsJson, + self_hosted_base_url: sh?.baseUrl ?? null, + self_hosted_model: sh?.model ?? null, + self_hosted_api_key: sh?.apiKey ?? null, + self_hosted_format: sh?.format ?? null, + self_hosted_system_prompt_template: + sh?.systemPromptTemplate ?? null, + self_hosted_user_message_template: + sh?.userMessageTemplate ?? null, }, ]) .onConflict((oc) => oc.column('org_id').doUpdateSet({ - api_key: credential.apiKey, + api_key: credential.apiKey ?? null, labeler_versions: labelerVersionsJson, + self_hosted_base_url: sh?.baseUrl ?? null, + self_hosted_model: sh?.model ?? null, + self_hosted_api_key: sh?.apiKey ?? null, + self_hosted_format: sh?.format ?? null, + self_hosted_system_prompt_template: + sh?.systemPromptTemplate ?? null, + self_hosted_user_message_template: + sh?.userMessageTemplate ?? null, }), ) - .returning(['api_key', 'labeler_versions']) + .returning([ + 'api_key', + 'labeler_versions', + 'self_hosted_base_url', + 'self_hosted_model', + 'self_hosted_api_key', + 'self_hosted_format', + 'self_hosted_system_prompt_template', + 'self_hosted_user_message_template', + ]) .executeTakeFirstOrThrow(); const returnedVersions = row.labeler_versions; + const returnedSelfHosted: ZentropiSelfHostedConfig | undefined = + row.self_hosted_base_url != null && row.self_hosted_model != null + ? { + format: (row.self_hosted_format ?? 'cope') as + | 'cope' + | 'openai_chat', + baseUrl: row.self_hosted_base_url, + model: row.self_hosted_model, + apiKey: row.self_hosted_api_key ?? undefined, + systemPromptTemplate: + row.self_hosted_system_prompt_template ?? undefined, + userMessageTemplate: + row.self_hosted_user_message_template ?? undefined, + } + : undefined; return { - apiKey: row.api_key, + apiKey: row.api_key ?? undefined, labelerVersions: Array.isArray(returnedVersions) ? (returnedVersions as ZentropiLabelerVersion[]) : typeof returnedVersions === 'string' - ? jsonParse(returnedVersions as JsonOf) - : [], + ? jsonParse(returnedVersions as JsonOf) + : [], + selfHosted: returnedSelfHosted, }; }, delete: async (orgId: string) => { diff --git a/server/services/signalsService/helpers/instantiateBuiltInSignals.ts b/server/services/signalsService/helpers/instantiateBuiltInSignals.ts index d7a67280f..6ec7304e0 100644 --- a/server/services/signalsService/helpers/instantiateBuiltInSignals.ts +++ b/server/services/signalsService/helpers/instantiateBuiltInSignals.ts @@ -59,6 +59,7 @@ export function instantiateBuiltInSignals( openAiModerationFetcher: getOpenAiScores, openAiWhisperTranscriptionFetcher: getOpenAiTranscription, zentropiFetcher: getZentropiScores, + openAICompatibleFetcher: getOpenAICompatibleScore, } = cachedFetchers; return { @@ -149,6 +150,7 @@ export function instantiateBuiltInSignals( [SignalType.ZENTROPI_LABELER]: new ZentropiLabelerSignal( credentialGetters.ZENTROPI, getZentropiScores, + getOpenAICompatibleScore, ), // Satisfies check to make sure we didn't forget any signals. } satisfies { [K in BuiltInSignalType]: SignalBase }; diff --git a/server/services/signalsService/helpers/makeCachedFetchers.ts b/server/services/signalsService/helpers/makeCachedFetchers.ts index df0d6fa3e..fb9d90ed2 100644 --- a/server/services/signalsService/helpers/makeCachedFetchers.ts +++ b/server/services/signalsService/helpers/makeCachedFetchers.ts @@ -12,6 +12,7 @@ import { type JSON } from '../../../utils/json-schema-types.js'; import { getGoogleContentSafetyScores } from '../signals/third_party_signals/google/content_safety/googleContentSafetyLib.js'; import { getOpenAiModerationScores } from '../signals/third_party_signals/open_ai/moderation/openAIModerationUtils.js'; import { getOpenAiTranscription } from '../signals/third_party_signals/open_ai/whisper/OpenAiWhisperTranscriptionSignal.js'; +import { getOpenAICompatibleScore } from '../signals/third_party_signals/openai_compatible/openaiCompatibleUtils.js'; import { getZentropiScores } from '../signals/third_party_signals/zentropi/zentropiUtils.js'; export type CachedFetchers = ReturnType; @@ -42,6 +43,9 @@ export function makeCachedFetchers( getOpenAiTranscription.bind(null, fetchHTTP), ), zentropiFetcher: toCachedFetcher(getZentropiScores.bind(null, fetchHTTP)), + openAICompatibleFetcher: toCachedFetcher( + getOpenAICompatibleScore.bind(null, fetchHTTP), + ), }; } diff --git a/server/services/signalsService/signals/third_party_signals/openai_compatible/openaiCompatibleUtils.ts b/server/services/signalsService/signals/third_party_signals/openai_compatible/openaiCompatibleUtils.ts new file mode 100644 index 000000000..2eec5cb7e --- /dev/null +++ b/server/services/signalsService/signals/third_party_signals/openai_compatible/openaiCompatibleUtils.ts @@ -0,0 +1,171 @@ +import { type Bind1 } from '../../../../../utils/typescript-types.js'; +import { makeSignalPermanentError } from '../../../../../utils/errors.js'; +import { jsonStringify } from '../../../../../utils/encoding.js'; +import { type FetchHTTP } from '../../../../networkingService/index.js'; + +/** + * Prompt template used by the CoPE model (cope-a-9b) when self-hosted. + * Matches the template from the official Zentropi sample notebook exactly. + */ +const COPE_PROMPT_TEMPLATE = `INSTRUCTIONS +============ + +Examine the given POLICY and determine if the given CONTENT meets the criteria for ANY of the LABELS. Answer "1" if yes, and "0" if no. + +POLICY +====== + +{criteria} + + +CONTENT +======= + +{content} + + +ANSWER +====== + +`; + +export type OpenAICompatibleFormat = 'cope' | 'openai_chat'; + +export type OpenAICompatibleClassifierParams = { + baseUrl: string; + model: string; + apiKey?: string; + criteria: string; + content: string; +} & ( + | { format: 'cope' } + | { + format: 'openai_chat'; + systemPromptTemplate: string; + userMessageTemplate: string; + } +); + +export type FetchOpenAICompatibleScore = Bind1< + typeof getOpenAICompatibleScore, + FetchHTTP +>; + +/** + * Converts a logprob (log probability) and the predicted label token to a + * 0–1 score matching the Zentropi hosted API convention: + * label=1 → confidence (higher = more likely violating) + * label=0 → 1 - confidence (higher = more likely violating) + */ +export function scoreFromLogprob( + label: '0' | '1', + logprob: number, +): number { + const confidence = Math.exp(logprob); + return label === '1' ? confidence : 1 - confidence; +} + +/** + * Calls an OpenAI-compatible completions or chat/completions endpoint to + * classify text against a policy, returning a 0–1 score. + * + * Reusable by any integration that self-hosts a classification model via + * vLLM or another OpenAI-compatible inference server. + */ +export async function getOpenAICompatibleScore( + fetchHTTP: FetchHTTP, + params: OpenAICompatibleClassifierParams, +): Promise<{ score: number }> { + const { baseUrl, model, apiKey, criteria, content, format } = params; + + const headers: Record = { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }; + + let response; + + if (format === 'cope') { + const prompt = COPE_PROMPT_TEMPLATE.replace('{criteria}', criteria).replace( + '{content}', + content, + ); + + response = await fetchHTTP({ + url: `${baseUrl}/v1/completions`, + method: 'post', + headers, + body: jsonStringify({ model, prompt, max_tokens: 1, logprobs: 1 }), + handleResponseBody: 'as-json', + timeoutMs: 10_000, + }); + + if (!response.ok) { + throwResponseError(response.status, baseUrl); + } + + const body = response.body as { + choices: { text: string; logprobs: { token_logprobs: number[] } }[]; + }; + const choice = body.choices[0]; + const label = choice.text.trim() as '0' | '1'; + const logprob = choice.logprobs.token_logprobs[0]; + return { score: scoreFromLogprob(label, logprob) }; + } else { + // openai_chat format + const { systemPromptTemplate, userMessageTemplate } = + params; + + const systemContent = systemPromptTemplate.replace('{criteria}', criteria); + const userContent = userMessageTemplate.replace('{content}', content); + + response = await fetchHTTP({ + url: `${baseUrl}/v1/chat/completions`, + method: 'post', + headers, + body: jsonStringify({ + model, + messages: [ + { role: 'system', content: systemContent }, + { role: 'user', content: userContent }, + ], + max_tokens: 1, + logprobs: true, + top_logprobs: 2, + }), + handleResponseBody: 'as-json', + timeoutMs: 10_000, + }); + + if (!response.ok) { + throwResponseError(response.status, baseUrl); + } + + const body = response.body as { + choices: { + message: { content: string }; + logprobs: { content: { token: string; logprob: number }[] }; + }[]; + }; + const choice = body.choices[0]; + const label = choice.message.content.trim() as '0' | '1'; + const logprob = choice.logprobs.content[0]?.logprob ?? 0; + return { score: scoreFromLogprob(label, logprob) }; + } +} + +function throwResponseError(status: number, baseUrl: string): never { + if (status === 401 || status === 403) { + throw makeSignalPermanentError( + `Self-hosted model API error: ${status} (invalid API key for ${baseUrl})`, + { shouldErrorSpan: true }, + ); + } + if (status === 404) { + throw makeSignalPermanentError( + `Self-hosted model API error: 404 (check base URL and model name — ${baseUrl})`, + { shouldErrorSpan: true }, + ); + } + throw new Error(`Self-hosted model API error: ${status}`); +} diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.test.ts b/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.test.ts index a5aa39600..db226a13e 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.test.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.test.ts @@ -4,6 +4,7 @@ import { type CachedGetCredentials } from '../../../../signalAuthService/signalA import { Integration } from '../../../types/Integration.js'; import { SignalType } from '../../../types/SignalType.js'; import { type SignalInput } from '../../SignalBase.js'; +import { type FetchOpenAICompatibleScore } from '../openai_compatible/openaiCompatibleUtils.js'; import ZentropiLabelerSignal from './ZentropiLabelerSignal.js'; import { type FetchZentropiScores, @@ -38,9 +39,22 @@ function makeInput( } as unknown as StringSignalInput; } +function makeOpenAICompatibleFetcher(): FetchOpenAICompatibleScore { + return Object.assign( + jest + .fn() + .mockResolvedValue({ score: 0 }) as unknown as FetchOpenAICompatibleScore, + { close: jest.fn().mockResolvedValue(undefined) }, + ); +} + describe('ZentropiLabelerSignal', () => { it('has correct signal metadata', () => { - const signal = new ZentropiLabelerSignal(makeCredentialGetter(), jest.fn()); + const signal = new ZentropiLabelerSignal( + makeCredentialGetter(), + jest.fn(), + makeOpenAICompatibleFetcher(), + ); expect(signal.id).toEqual({ type: SignalType.ZENTROPI_LABELER }); expect(signal.displayName).toBe('Zentropi Labeler'); @@ -59,6 +73,7 @@ describe('ZentropiLabelerSignal', () => { const signal = new ZentropiLabelerSignal( makeCredentialGetter(null), jest.fn(), + makeOpenAICompatibleFetcher(), ); const info = await signal.getDisabledInfo('org-1'); @@ -70,6 +85,7 @@ describe('ZentropiLabelerSignal', () => { const signal = new ZentropiLabelerSignal( makeCredentialGetter('key'), jest.fn(), + makeOpenAICompatibleFetcher(), ); const info = await signal.getDisabledInfo('org-1'); @@ -85,6 +101,7 @@ describe('ZentropiLabelerSignal', () => { const signal = new ZentropiLabelerSignal( makeCredentialGetter(), fetchScores, + makeOpenAICompatibleFetcher(), ); const result = await signal.run(makeInput()); diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.ts b/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.ts index 8f0607e66..c55c4a94b 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.ts @@ -5,6 +5,7 @@ import { Integration } from '../../../types/Integration.js'; import { SignalPricingStructure } from '../../../types/SignalPricingStructure.js'; import { SignalType } from '../../../types/SignalType.js'; import SignalBase, { type SignalInput } from '../../SignalBase.js'; +import { type FetchOpenAICompatibleScore } from '../openai_compatible/openaiCompatibleUtils.js'; import { runZentropiLabelerImpl, type FetchZentropiScores, @@ -17,6 +18,7 @@ export default class ZentropiLabelerSignal extends SignalBase< constructor( protected readonly getZentropiCredentials: CachedGetCredentials<'ZENTROPI'>, protected readonly getZentropiScores: FetchZentropiScores, + protected readonly fetchOpenAICompatibleScore: FetchOpenAICompatibleScore, ) { super(); } @@ -31,10 +33,11 @@ export default class ZentropiLabelerSignal extends SignalBase< override get description() { return ( - 'Policy-steerable content classifier powered by Zentropi. ' + - 'Evaluates text against a custom policy defined by a published labeler. ' + + 'Policy-steerable content classifier powered by the CoPE model. ' + + 'Evaluates text against a custom policy. ' + 'Returns a composite score: 0 = confidently safe, 0.5 = uncertain, 1 = confidently violating. ' + - 'Specify the labeler_version_id in the subcategory field.' + 'For Zentropi hosted: specify the labeler_version_id in the subcategory field. ' + + 'For self-hosted: specify the policy criteria text in the subcategory field.' ); } @@ -75,11 +78,14 @@ export default class ZentropiLabelerSignal extends SignalBase< override async getDisabledInfo(orgId: string) { const credential = await this.getZentropiCredentials(orgId); + if (credential?.selfHosted != null) { + return { disabled: false as const }; + } return !credential?.apiKey ? { disabled: true as const, disabledMessage: - 'You need to input your Zentropi API key to use Zentropi signals', + 'You need to configure either a Zentropi API key or a self-hosted model endpoint to use this signal', } : { disabled: false as const }; } @@ -108,6 +114,7 @@ export default class ZentropiLabelerSignal extends SignalBase< this.getZentropiCredentials, input, this.getZentropiScores, + this.fetchOpenAICompatibleScore, ); } } diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts index 5ce4daa0d..986e5540e 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts @@ -4,6 +4,7 @@ import { isCoopErrorOfType } from '../../../../../utils/errors.js'; import { type FetchHTTP } from '../../../../networkingService/index.js'; import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; import { type SignalInput } from '../../SignalBase.js'; +import { type FetchOpenAICompatibleScore } from '../openai_compatible/openaiCompatibleUtils.js'; import { getZentropiScores, runZentropiLabelerImpl, @@ -39,6 +40,15 @@ function makeCredentialGetter( ); } +function makeOpenAICompatibleFetcher(): FetchOpenAICompatibleScore { + return Object.assign( + jest + .fn() + .mockResolvedValue({ score: 0 }) as unknown as FetchOpenAICompatibleScore, + { close: jest.fn().mockResolvedValue(undefined) }, + ); +} + describe('zentropiUtils', () => { describe('score mapping', () => { it('maps label=1, high confidence to high score (violating)', async () => { @@ -51,6 +61,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBe(0.95); @@ -66,6 +77,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBeCloseTo(0.05); @@ -81,6 +93,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBeCloseTo(0.4); @@ -96,6 +109,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBe(0.6); @@ -111,6 +125,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBe(0.95); @@ -126,6 +141,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.score).toBeCloseTo(0.05); @@ -141,6 +157,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(result.outputType).toEqual({ scalarType: ScalarTypes.NUMBER }); @@ -156,6 +173,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(null), makeInput(), fetchScores, + makeOpenAICompatibleFetcher(), ), ).rejects.toThrow('Missing Zentropi API credentials'); }); @@ -168,8 +186,9 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput({ subcategory: undefined }), fetchScores, + makeOpenAICompatibleFetcher(), ), - ).rejects.toThrow('Missing labeler_version_id in subcategory'); + ).rejects.toThrow('Missing criteria in subcategory'); }); it('passes labelerVersionId from subcategory to fetcher', async () => { @@ -182,6 +201,7 @@ describe('zentropiUtils', () => { makeCredentialGetter(), makeInput({ subcategory: 'lv_custom_123' }), fetchScores, + makeOpenAICompatibleFetcher(), ); expect(fetchScores).toHaveBeenCalledWith({ diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts index dbb8233a1..55b66e135 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts @@ -6,6 +6,7 @@ import { type Bind1 } from '../../../../../utils/typescript-types.js'; import { type FetchHTTP } from '../../../../networkingService/index.js'; import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; import { type SignalInput } from '../../SignalBase.js'; +import { type FetchOpenAICompatibleScore } from '../openai_compatible/openaiCompatibleUtils.js'; export interface ZentropiResponse { label: 0 | 1 | '0' | '1'; @@ -58,24 +59,49 @@ export async function getZentropiScores( export async function runZentropiLabelerImpl( getZentropiCredentials: CachedGetCredentials<'ZENTROPI'>, input: SignalInput, - fetchScores: FetchZentropiScores, + fetchZentropiScores: FetchZentropiScores, + fetchOpenAICompatibleScore: FetchOpenAICompatibleScore, ) { const { value, orgId, subcategory } = input; const credential = await getZentropiCredentials(orgId); - if (!credential?.apiKey) { - throw new Error('Missing Zentropi API credentials'); - } - if (!subcategory) { throw new Error( - 'Missing labeler_version_id in subcategory. ' + - 'Specify a Zentropi labeler_version_id in the condition subcategory field.', + 'Missing criteria in subcategory. ' + + 'Specify a Zentropi labeler_version_id (hosted) or policy criteria text (self-hosted) ' + + 'in the condition subcategory field.', ); } - const response = await fetchScores({ + if (credential?.selfHosted != null) { + const { selfHosted } = credential; + const base = { + baseUrl: selfHosted.baseUrl, + model: selfHosted.model, + apiKey: selfHosted.apiKey, + criteria: subcategory, + content: value.value, + }; + const params = + selfHosted.format === 'openai_chat' + ? { + ...base, + format: 'openai_chat' as const, + systemPromptTemplate: + selfHosted.systemPromptTemplate ?? '{criteria}', + userMessageTemplate: selfHosted.userMessageTemplate ?? '{content}', + } + : { ...base, format: 'cope' as const }; + const { score } = await fetchOpenAICompatibleScore(params); + return { score, outputType: { scalarType: ScalarTypes.NUMBER } }; + } + + if (!credential?.apiKey) { + throw new Error('Missing Zentropi API credentials'); + } + + const response = await fetchZentropiScores({ text: value.value, apiKey: credential.apiKey, labelerVersionId: subcategory, From f1634f53ccf834ea1e327269a2e3ac4726da8027 Mon Sep 17 00:00:00 2001 From: Mark Reitblatt Date: Wed, 17 Jun 2026 14:48:32 -0700 Subject: [PATCH 2/7] [Feature] Add free-text policy criteria input for Zentropi self-hosted rules (#751) When Zentropi is configured in self-hosted mode, the rule builder now shows a free-text textarea to enter policy criteria, rather than the standard subcategory gallery. Hosted mode continues to show labeler versions as a dropdown. Also removes temporary debug logging from zentropiUtils. Co-Authored-By: Claude Sonnet 4.6 --- .../RuleFormConditionSignalSubcategory.tsx | 27 +++++++++++-- .../RuleFormSignalModalSubcategoryGallery.tsx | 38 ++++++++++++++++++- server/graphql/modules/signal.ts | 13 ++++++- .../zentropi/zentropiUtils.ts | 1 - 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx b/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx index 04489e978..b2d919fc4 100644 --- a/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx @@ -20,6 +20,20 @@ export default function RuleFormConditionSignalSubcategory(props: { } const { conditionIndex, conditionSetIndex } = location; + const isFreeText = + eligibleSubcategories.length === 1 && + eligibleSubcategories[0].id === '__free_text__'; + + const displayValue = signal.subcategory + ? isFreeText + ? signal.subcategory.length > 40 + ? signal.subcategory.slice(0, 40) + '…' + : signal.subcategory + : signal.subcategory + : isFreeText + ? 'Enter Criteria' + : 'Select Subcategory'; + return (
-
Signal Subcategory
+
+ {isFreeText ? 'Policy Criteria' : 'Signal Subcategory'} +
-
Signal Subcategory
+
+ {isFreeText ? 'Policy Criteria' : 'Signal Subcategory'} +
); } diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx index ee209ea21..f543cc14e 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx @@ -1,5 +1,5 @@ import { SearchOutlined } from '@ant-design/icons'; -import { Input, Select } from 'antd'; +import { Button, Input, Select } from 'antd'; import omit from 'lodash/omit'; import { useState } from 'react'; @@ -14,8 +14,9 @@ export function RuleFormSignalModalSubcategoryGallery(props: { subcategories: readonly GQLSignalSubcategory[]; onSelectSubcategoryOption: (option: string) => void; }) { - const { subcategories, onSelectSubcategoryOption } = props; + const { signal, subcategories, onSelectSubcategoryOption } = props; const [searchTerm, setSearchTerm] = useState(''); + const [freeText, setFreeText] = useState(signal.subcategory ?? ''); const stripped = subcategories.map((subcategory) => omit(subcategory, '__typename'), @@ -25,6 +26,39 @@ export function RuleFormSignalModalSubcategoryGallery(props: { // Flat subcategories have no childrenIds, so the tree rebuild filters them all out. const hasTreeStructure = stripped.some((s) => s.childrenIds.length > 0); + // Sentinel used when a signal needs free-text criteria (e.g. Zentropi self-hosted). + const isFreeText = + !hasTreeStructure && + stripped.length === 1 && + stripped[0].id === '__free_text__'; + + if (isFreeText) { + return ( +
+
Enter Policy Criteria
+
+ Describe the policy the model should evaluate content against. +
+ setFreeText(e.target.value)} + /> +
+ +
+
+ ); + } + if (!hasTreeStructure && stripped.length > 0) { // Render a simple dropdown for flat subcategories (e.g. Zentropi labeler versions) return ( diff --git a/server/graphql/modules/signal.ts b/server/graphql/modules/signal.ts index 060d0c35f..c21bdc329 100644 --- a/server/graphql/modules/signal.ts +++ b/server/graphql/modules/signal.ts @@ -262,7 +262,6 @@ const Signal: GQLSignalResolvers = { } }, async eligibleSubcategories(signal, _, context) { - // For Zentropi signals, return org-specific labeler versions as subcategories if (signal.id.type === 'ZENTROPI_LABELER') { const user = context.getUser(); if (user) { @@ -271,6 +270,18 @@ const Signal: GQLSignalResolvers = { 'ZENTROPI', ); if (config?.name === 'ZENTROPI') { + // Self-hosted mode: criteria text is entered free-form in the rule builder. + // Return a sentinel that tells the UI to show a free-text textarea. + if (config.apiCredential.selfHosted != null) { + return [ + { + id: '__free_text__', + label: 'Enter policy criteria', + childrenIds: [], + }, + ]; + } + // Hosted mode: return labeler versions configured in the integration. const versions = (config.apiCredential.labelerVersions ?? []) as Array<{ id: string; diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts index 55b66e135..1004aa183 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts @@ -63,7 +63,6 @@ export async function runZentropiLabelerImpl( fetchOpenAICompatibleScore: FetchOpenAICompatibleScore, ) { const { value, orgId, subcategory } = input; - const credential = await getZentropiCredentials(orgId); if (!subcategory) { From a8827bcb5d3dd675190b7f806e65906d14ee86db Mon Sep 17 00:00:00 2001 From: Mark Reitblatt Date: Thu, 18 Jun 2026 11:48:23 -0700 Subject: [PATCH 3/7] [Fix] Handle HTML-only policyText in Zentropi self-hosted signal (#751) Policies created via the tiptap editor store empty state as "

" rather than null. This passes the policyText truthiness filter but strips to an empty string, which would produce meaningless classifier criteria. Fix in two places: - resolvePolicyCriteria now throws a SignalPermanentError if the HTML-stripped criteria text is empty, rather than silently passing "" to the self-hosted model endpoint. - eligibleSubcategories filter now also strips HTML before filtering, so policies with HTML-only text never appear in the policy picker and can't be selected as signal criteria. Co-Authored-By: Claude Sonnet 4.6 --- server/graphql/modules/signal.ts | 29 ++++++- .../zentropi/zentropiUtils.test.ts | 87 +++++++++++++++++++ .../zentropi/zentropiUtils.ts | 45 +++++++++- 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/server/graphql/modules/signal.ts b/server/graphql/modules/signal.ts index c21bdc329..47afd1e1d 100644 --- a/server/graphql/modules/signal.ts +++ b/server/graphql/modules/signal.ts @@ -270,13 +270,36 @@ const Signal: GQLSignalResolvers = { 'ZENTROPI', ); if (config?.name === 'ZENTROPI') { - // Self-hosted mode: criteria text is entered free-form in the rule builder. - // Return a sentinel that tells the UI to show a free-text textarea. + // Self-hosted mode: offer existing org policies as selectable options + // plus a free-text sentinel for custom criteria. if (config.apiCredential.selfHosted != null) { + const policies = + await context.services.ModerationConfigService.getPolicies({ + orgId: user.orgId, + readFromReplica: true, + }); + // Only surface policies that have usable text after stripping HTML — + // policies with empty or HTML-only text would cause a + // SignalPermanentError at run time. + const policyOptions = policies + .filter((p) => { + if (!p.policyText) return false; + const stripped = p.policyText + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return stripped.length > 0; + }) + .map((p) => ({ + id: `policy:${p.id}`, + label: p.name, + childrenIds: [], + })); return [ + ...policyOptions, { id: '__free_text__', - label: 'Enter policy criteria', + label: 'Enter custom criteria', childrenIds: [], }, ]; diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts index 986e5540e..32692ac59 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts @@ -9,6 +9,7 @@ import { getZentropiScores, runZentropiLabelerImpl, type FetchZentropiScores, + type GetPolicyText, type ZentropiResponse, } from './zentropiUtils.js'; @@ -49,6 +50,10 @@ function makeOpenAICompatibleFetcher(): FetchOpenAICompatibleScore { ); } +function makeGetPolicyText(text: string | null = null): GetPolicyText { + return jest.fn().mockResolvedValue(text); +} + describe('zentropiUtils', () => { describe('score mapping', () => { it('maps label=1, high confidence to high score (violating)', async () => { @@ -62,6 +67,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBe(0.95); @@ -78,6 +84,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBeCloseTo(0.05); @@ -94,6 +101,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBeCloseTo(0.4); @@ -110,6 +118,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBe(0.6); @@ -126,6 +135,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBe(0.95); @@ -142,6 +152,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.score).toBeCloseTo(0.05); @@ -158,6 +169,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(result.outputType).toEqual({ scalarType: ScalarTypes.NUMBER }); @@ -174,6 +186,7 @@ describe('zentropiUtils', () => { makeInput(), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ), ).rejects.toThrow('Missing Zentropi API credentials'); }); @@ -187,6 +200,7 @@ describe('zentropiUtils', () => { makeInput({ subcategory: undefined }), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ), ).rejects.toThrow('Missing criteria in subcategory'); }); @@ -202,6 +216,7 @@ describe('zentropiUtils', () => { makeInput({ subcategory: 'lv_custom_123' }), fetchScores, makeOpenAICompatibleFetcher(), + makeGetPolicyText(), ); expect(fetchScores).toHaveBeenCalledWith({ @@ -210,6 +225,78 @@ describe('zentropiUtils', () => { labelerVersionId: 'lv_custom_123', }); }); + + it('resolves policy:id to policy text for hosted mode', async () => { + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ + label: 1, + confidence: 0.9, + } satisfies ZentropiResponse); + + await runZentropiLabelerImpl( + makeCredentialGetter(), + makeInput({ subcategory: 'policy:abc123' }), + fetchScores, + makeOpenAICompatibleFetcher(), + makeGetPolicyText('No hate speech allowed'), + ); + + expect(fetchScores).toHaveBeenCalledWith({ + text: 'test content', + apiKey: 'test-api-key', + labelerVersionId: 'No hate speech allowed', + }); + }); + + it('throws SignalPermanentError when policy has no text', async () => { + const fetchScores: FetchZentropiScores = jest.fn(); + + await expect( + runZentropiLabelerImpl( + makeCredentialGetter(), + makeInput({ subcategory: 'policy:missing' }), + fetchScores, + makeOpenAICompatibleFetcher(), + makeGetPolicyText(null), + ), + ).rejects.toThrow('not found or has no policy text'); + }); + + it('strips HTML from policy text before sending', async () => { + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ + label: 0, + confidence: 0.8, + } satisfies ZentropiResponse); + + await runZentropiLabelerImpl( + makeCredentialGetter(), + makeInput({ subcategory: 'policy:abc123' }), + fetchScores, + makeOpenAICompatibleFetcher(), + makeGetPolicyText('

No hate speech allowed.

'), + ); + + expect(fetchScores).toHaveBeenCalledWith({ + text: 'test content', + apiKey: 'test-api-key', + labelerVersionId: 'No hate speech allowed.', + }); + }); + + it('throws SignalPermanentError when policy text is HTML-only (e.g. tiptap empty state)', async () => { + const fetchScores: FetchZentropiScores = jest.fn(); + + await expect( + runZentropiLabelerImpl( + makeCredentialGetter(), + makeInput({ subcategory: 'policy:abc123' }), + fetchScores, + makeOpenAICompatibleFetcher(), + makeGetPolicyText('

'), + ), + ).rejects.toThrow( + 'has no usable criteria text after removing HTML formatting', + ); + }); }); describe('getZentropiScores', () => { diff --git a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts index 1004aa183..959978a15 100644 --- a/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts +++ b/server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts @@ -8,6 +8,11 @@ import { type CachedGetCredentials } from '../../../../signalAuthService/signalA import { type SignalInput } from '../../SignalBase.js'; import { type FetchOpenAICompatibleScore } from '../openai_compatible/openaiCompatibleUtils.js'; +export type GetPolicyText = ( + orgId: string, + policyId: string, +) => Promise; + export interface ZentropiResponse { label: 0 | 1 | '0' | '1'; confidence: number; @@ -61,6 +66,7 @@ export async function runZentropiLabelerImpl( input: SignalInput, fetchZentropiScores: FetchZentropiScores, fetchOpenAICompatibleScore: FetchOpenAICompatibleScore, + getPolicyText: GetPolicyText, ) { const { value, orgId, subcategory } = input; const credential = await getZentropiCredentials(orgId); @@ -73,13 +79,22 @@ export async function runZentropiLabelerImpl( ); } + // Resolve policy reference to criteria text, stripping any HTML markup. + const resolvedCriteria = subcategory.startsWith('policy:') + ? await resolvePolicyCriteria( + getPolicyText, + orgId, + subcategory.slice('policy:'.length), + ) + : subcategory; + if (credential?.selfHosted != null) { const { selfHosted } = credential; const base = { baseUrl: selfHosted.baseUrl, model: selfHosted.model, apiKey: selfHosted.apiKey, - criteria: subcategory, + criteria: resolvedCriteria, content: value.value, }; const params = @@ -103,7 +118,7 @@ export async function runZentropiLabelerImpl( const response = await fetchZentropiScores({ text: value.value, apiKey: credential.apiKey, - labelerVersionId: subcategory, + labelerVersionId: resolvedCriteria, }); // Composite score mapping: @@ -118,3 +133,29 @@ export async function runZentropiLabelerImpl( outputType: { scalarType: ScalarTypes.NUMBER }, }; } + +async function resolvePolicyCriteria( + getPolicyText: GetPolicyText, + orgId: string, + policyId: string, +): Promise { + const text = await getPolicyText(orgId, policyId); + if (!text) { + throw makeSignalPermanentError( + `Policy ${policyId} not found or has no policy text`, + { shouldErrorSpan: true }, + ); + } + // Strip HTML tags that may be present in rich-text policy descriptions. + const stripped = text + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!stripped) { + throw makeSignalPermanentError( + `Policy ${policyId} has no usable criteria text after removing HTML formatting`, + { shouldErrorSpan: true }, + ); + } + return stripped; +} From ced2e14444e4d0c7157d623b5ae251ad141cf608 Mon Sep 17 00:00:00 2001 From: Mark Reitblatt Date: Fri, 19 Jun 2026 16:25:19 -0700 Subject: [PATCH 4/7] [Feature] Allow selecting an existing Coop policy as Zentropi signal criteria (#751) Self-hosted Zentropi rules can now use an existing org policy object as classifier criteria instead of free text: - Signal subcategory gallery shows a Radio toggle between "Existing Policy" (Select dropdown) and "Custom Text" (textarea) when the org has at least one policy with usable text. - Selecting a policy stores subcategory as "policy:"; the server resolves this to the policy's stripped policyText at signal run time. - Condition display shows the policy name (not the raw ID) when a policy reference is stored. - When a policy is chosen as criteria and no policies are assigned to the rule yet, policyIds is auto-populated with that policy's ID. - ZentropiLabelerSignal now accepts a getPolicyText callback (injected via instantiateBuiltInSignals) that fetches policyText from ModerationConfigService at run time. Co-Authored-By: Claude Sonnet 4.6 --- .../rules/rule_form/RuleFormReducers.tsx | 17 +++- .../RuleFormConditionSignalSubcategory.tsx | 54 +++++++---- .../RuleFormSignalModalSubcategoryGallery.tsx | 97 +++++++++++++++++-- .../services/signalsService/SignalsService.ts | 1 + .../helpers/instantiateBuiltInSignals.ts | 11 +++ .../zentropi/ZentropiLabelerSignal.test.ts | 9 ++ .../zentropi/ZentropiLabelerSignal.ts | 3 + 7 files changed, 156 insertions(+), 36 deletions(-) diff --git a/client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx b/client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx index 6556beca2..707edf547 100644 --- a/client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/RuleFormReducers.tsx @@ -568,10 +568,8 @@ export function getNewEligibleInputs( // Note: this filter isn't technically needed, but in the future we might // allow non-custom signals to run on content types, so we keep it here // so future devs don't need to remember to add it. - return eligibleSignals.filter( - (it) => - it.type === GQLSignalType.Custom, - ).length; + return eligibleSignals.filter((it) => it.type === GQLSignalType.Custom) + .length; }) .map((itemType) => ({ type: 'FULL_ITEM' as const, @@ -898,7 +896,7 @@ export function updateSignalSubcategory( }, ): RuleFormState { const { location, subcategory } = action.payload; - return updateConditionComponent( + const next = updateConditionComponent( state, location, subcategory, @@ -907,6 +905,15 @@ export function updateSignalSubcategory( return condition; }, ); + + // When a policy is chosen as signal criteria and no policies are assigned yet, + // default the rule's policy assignment to that same policy. + if (subcategory.startsWith('policy:') && next.policyIds.length === 0) { + const policyId = subcategory.slice('policy:'.length); + return { ...next, policyIds: [policyId] }; + } + + return next; } export function updateMatchingValues( diff --git a/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx b/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx index b2d919fc4..9cba6816f 100644 --- a/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/condition/signal/RuleFormConditionSignalSubcategory.tsx @@ -20,19 +20,37 @@ export default function RuleFormConditionSignalSubcategory(props: { } const { conditionIndex, conditionSetIndex } = location; - const isFreeText = - eligibleSubcategories.length === 1 && - eligibleSubcategories[0].id === '__free_text__'; + const hasFreeTextSentinel = eligibleSubcategories.some( + (s) => s.id === '__free_text__', + ); + const hasPolicyOptions = eligibleSubcategories.some((s) => + s.id.startsWith('policy:'), + ); + const isSelfHosted = hasFreeTextSentinel; + + // Look up the label for the stored subcategory ID (e.g. policy name). + const matchingOption = signal.subcategory + ? eligibleSubcategories.find((s) => s.id === signal.subcategory) + : undefined; + + let displayValue: string; + let tooltipText: string | undefined; + + if (!signal.subcategory) { + displayValue = hasPolicyOptions + ? 'Select Policy or Criteria' + : 'Enter Criteria'; + } else if (matchingOption) { + // Subcategory is a known option (e.g. a policy) — show its label. + displayValue = matchingOption.label; + } else { + // Subcategory is raw free text — truncate for display, full text as tooltip. + const text = signal.subcategory; + displayValue = text.length > 40 ? text.slice(0, 40) + '…' : text; + tooltipText = text; + } - const displayValue = signal.subcategory - ? isFreeText - ? signal.subcategory.length > 40 - ? signal.subcategory.slice(0, 40) + '…' - : signal.subcategory - : signal.subcategory - : isFreeText - ? 'Enter Criteria' - : 'Select Subcategory'; + const label = isSelfHosted ? 'Policy Criteria' : 'Signal Subcategory'; return (
-
- {isFreeText ? 'Policy Criteria' : 'Signal Subcategory'} -
+
{label}
-
- {isFreeText ? 'Policy Criteria' : 'Signal Subcategory'} -
+
{label}
); } diff --git a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx index f543cc14e..964c21420 100644 --- a/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx @@ -1,5 +1,5 @@ import { SearchOutlined } from '@ant-design/icons'; -import { Button, Input, Select } from 'antd'; +import { Button, Input, Radio, Select } from 'antd'; import omit from 'lodash/omit'; import { useState } from 'react'; @@ -9,6 +9,13 @@ import { rebuildSubcategoryTreeFromGraphQLResponse } from '../../../../../utils/ import RuleFormSignalModalNoSearchResults from './RuleFormSignalModalNoSearchResults'; import { RuleFormSignalModalSubcategory } from './RuleFormSignalModalSubcategory'; +type Mode = 'policy' | 'free_text'; + +function initialMode(signal: CoreSignal): Mode { + if (signal.subcategory?.startsWith('policy:')) return 'policy'; + return 'free_text'; +} + export function RuleFormSignalModalSubcategoryGallery(props: { signal: CoreSignal; subcategories: readonly GQLSignalSubcategory[]; @@ -16,23 +23,93 @@ export function RuleFormSignalModalSubcategoryGallery(props: { }) { const { signal, subcategories, onSelectSubcategoryOption } = props; const [searchTerm, setSearchTerm] = useState(''); - const [freeText, setFreeText] = useState(signal.subcategory ?? ''); + const [freeText, setFreeText] = useState( + signal.subcategory && !signal.subcategory.startsWith('policy:') + ? signal.subcategory + : '', + ); const stripped = subcategories.map((subcategory) => omit(subcategory, '__typename'), ); - // Check if subcategories are flat (no parent-child tree structure). - // Flat subcategories have no childrenIds, so the tree rebuild filters them all out. const hasTreeStructure = stripped.some((s) => s.childrenIds.length > 0); - // Sentinel used when a signal needs free-text criteria (e.g. Zentropi self-hosted). - const isFreeText = - !hasTreeStructure && - stripped.length === 1 && - stripped[0].id === '__free_text__'; + // Sentinel for free-text criteria entry. + const hasFreeTextSentinel = stripped.some((s) => s.id === '__free_text__'); + // Policy options are any subcategory whose id begins with "policy:". + const policyOptions = stripped.filter((s) => s.id.startsWith('policy:')); + const hasPolicyOptions = policyOptions.length > 0; + + // Mixed mode: org has policies AND free-text entry, show a toggle. + const isMixed = hasFreeTextSentinel && hasPolicyOptions; + + const [mode, setMode] = useState(initialMode(signal)); + + if (isMixed) { + return ( +
+
Select Policy Criteria
+ setMode(e.target.value as Mode)} + > + Existing Policy + Custom Text + + + {mode === 'policy' && ( +
+
+ Select a policy — its text will be used as the classifier + criteria. +
+ - Validate email format client-side (rejecting commas, which were the original bug vector) and mark the field red when invalid - Show a contextual tooltip on the disabled submit button explaining what's missing (no email, invalid email, or no role selected) Fixes #411 * Destructure value for consistency, add aria-invalid for a11y --- .../dashboard/components/CoopInput.tsx | 13 +- .../ManageUsersInviteUserSection.test.tsx | 127 ++++++++++++++++++ .../settings/ManageUsersInviteUserSection.tsx | 15 ++- 3 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 client/src/webpages/settings/ManageUsersInviteUserSection.test.tsx diff --git a/client/src/webpages/dashboard/components/CoopInput.tsx b/client/src/webpages/dashboard/components/CoopInput.tsx index 17f3318b3..667bcf238 100644 --- a/client/src/webpages/dashboard/components/CoopInput.tsx +++ b/client/src/webpages/dashboard/components/CoopInput.tsx @@ -6,15 +6,22 @@ export default function CoopInput(props: { onChange?: (event: React.ChangeEvent) => void; disabled?: boolean; value?: string; + error?: boolean; }) { - const { placeholder, onChange, disabled } = props; + const { type, placeholder, onChange, disabled, value, error } = props; return ( ); } diff --git a/client/src/webpages/settings/ManageUsersInviteUserSection.test.tsx b/client/src/webpages/settings/ManageUsersInviteUserSection.test.tsx new file mode 100644 index 000000000..c97d3f82e --- /dev/null +++ b/client/src/webpages/settings/ManageUsersInviteUserSection.test.tsx @@ -0,0 +1,127 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import '@testing-library/jest-dom/extend-expect'; + +import { + GQLHasNcmecReportingEnabledDocument, + GQLRolesForOrgDocument, + GQLUserRole, +} from '@/graphql/generated'; + +import ManageUsersInviteUserSection from './ManageUsersInviteUserSection'; + +const ncmecMock: MockedResponse = { + request: { query: GQLHasNcmecReportingEnabledDocument }, + maxUsageCount: Infinity, + result: { + data: { + myOrg: { __typename: 'Organization', hasNCMECReportingEnabled: false }, + }, + }, +}; + +const rolesMock: MockedResponse = { + request: { query: GQLRolesForOrgDocument }, + maxUsageCount: Infinity, + result: { + data: { + rolesForOrg: [ + { + __typename: 'Role', + id: 'role-1', + key: GQLUserRole.Admin, + displayName: 'Admin', + description: '', + isSystem: true, + isFallback: false, + permissions: [], + userCount: 1, + }, + ], + }, + }, +}; + +function renderSection() { + return render( + + + + + , + ); +} + +async function waitForLoaded() { + await waitFor(() => { + expect( + screen.getByRole('button', { name: /send invite link/i }), + ).toBeInTheDocument(); + }); +} + +function getEmailInput() { + return screen.getByPlaceholderText('Email address'); +} + +function getSubmitButton() { + return screen.getByRole('button', { name: /send invite link/i }); +} + +describe('ManageUsersInviteUserSection', () => { + it('disables the button with no input', async () => { + renderSection(); + await waitForLoaded(); + expect(getSubmitButton()).toBeDisabled(); + }); + + it('disables the button for a plainly invalid email', async () => { + renderSection(); + await waitForLoaded(); + fireEvent.change(getEmailInput(), { target: { value: 'notanemail' } }); + expect(getSubmitButton()).toBeDisabled(); + }); + + it('disables the button for a comma-separated email list', async () => { + renderSection(); + await waitForLoaded(); + fireEvent.change(getEmailInput(), { + target: { value: 'foo@bar.com,baz@qux.com' }, + }); + expect(getSubmitButton()).toBeDisabled(); + }); + + it('disables the button when email is valid but no role is selected', async () => { + renderSection(); + await waitForLoaded(); + fireEvent.change(getEmailInput(), { + target: { value: 'user@example.com' }, + }); + expect(getSubmitButton()).toBeDisabled(); + }); + + it('enables the button with a valid email and a role selected', async () => { + renderSection(); + await waitForLoaded(); + fireEvent.change(getEmailInput(), { + target: { value: 'user@example.com' }, + }); + fireEvent.click(screen.getByRole('radio', { name: /admin/i })); + expect(getSubmitButton()).not.toBeDisabled(); + }); + + it('marks the email input as invalid and re-enables after correction', async () => { + renderSection(); + await waitForLoaded(); + + fireEvent.change(getEmailInput(), { target: { value: 'bad-email' } }); + expect(getEmailInput()).toHaveClass('ring-red-400'); + + fireEvent.change(getEmailInput(), { + target: { value: 'good@example.com' }, + }); + expect(getEmailInput()).not.toHaveClass('ring-red-400'); + }); +}); diff --git a/client/src/webpages/settings/ManageUsersInviteUserSection.tsx b/client/src/webpages/settings/ManageUsersInviteUserSection.tsx index 3558e2568..42344dd4c 100644 --- a/client/src/webpages/settings/ManageUsersInviteUserSection.tsx +++ b/client/src/webpages/settings/ManageUsersInviteUserSection.tsx @@ -154,6 +154,16 @@ export default function ManageUsersInviteUserSection() { }); }; + const emailIsInvalid = + Boolean(email?.length) && + !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(email ?? ''); + const isDisabled = !email?.length || emailIsInvalid || !role; + const disabledReason = !email?.length + ? 'Enter an email address to continue' + : emailIsInvalid + ? 'Enter a valid email address' + : 'Select a role to continue'; + return (
setEmail(e.target.value)} value={email} + error={emailIsInvalid} />
@@ -193,7 +204,9 @@ export default function ManageUsersInviteUserSection() { size="middle" onClick={onInviteUser} loading={loading} - disabled={!email?.length || !role} + disabled={isDisabled} + disabledTooltipTitle={disabledReason} + disabledTooltipPlacement="top" />
{roleModalVisible && ( From fb938e2c11b9d37a3d7079eaf0db0c39359a61e0 Mon Sep 17 00:00:00 2001 From: Jess Monroe Date: Thu, 18 Jun 2026 05:29:11 +0200 Subject: [PATCH 6/7] fix: don't hide empty threads (#804) * fix: don't hide empty threads * code review fixes * code review fixes --------- Co-authored-by: Juan Mrad --- .../ManualReviewJobThreadComponent.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/threads/ManualReviewJobThreadComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/threads/ManualReviewJobThreadComponent.tsx index 7393f209d..0765efecf 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/threads/ManualReviewJobThreadComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/threads/ManualReviewJobThreadComponent.tsx @@ -234,7 +234,15 @@ export function ManualReviewJobThreadComponent(props: { )?.data : undefined; }; - if (!data || data.threadHistory.length === 0) { + if (loading) { + return ; + } + + if (error) { + return
Error loading user submissions: {error.message}
; + } + + if (!data) { return null; } const newMessages = data.threadHistory @@ -354,14 +362,6 @@ export function ManualReviewJobThreadComponent(props: { ? `${threadTypeName} ID: ${thread.id}` : `Thread ID: ${thread.id}`; - if (loading) { - return ; - } - - if (error) { - return
Error loading user submissions: {error.message}
; - } - return ( <>
@@ -441,10 +441,16 @@ export function ManualReviewJobThreadComponent(props: { className="flex flex-col w-full overflow-auto max-h-[600px] gap-2 p-5" ref={scrollViewRef} > - {threadComponent} + {threadComponent.length > 0 ? ( + threadComponent + ) : ( +
+ There is no content in this thread yet +
+ )}
- {isActionable && ( + {isActionable && newMessages.length > 0 && ( <>