Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
2
fork

Configure Feed

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

dFeature/zentropi signal integration (#42)

* Add ZENTROPI integration and ZENTROPI_LABELER signal type enums

Add Zentropi as a new third-party integration and ZENTROPI_LABELER as
a new signal type across TypeScript enums, GraphQL schema, and
generated types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Zentropi credential storage and database migration

Add ZentropiCredential type, CRUD operations for per-org API key
storage, and migration to create signal_auth_service.zentropi_configs
table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Zentropi signal implementation

Implement ZentropiLabelerSignal and zentropiUtils following the OpenAI
Moderation pattern. Calls POST /v1/label with policy-steerable labeler,
maps label+confidence to a composite 0-1 score, and handles permanent
errors (401/404) vs transient errors (5xx).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Wire up Zentropi signal with cached fetcher and credential getter

Register ZentropiLabelerSignal in instantiateBuiltInSignals, add
cached Zentropi API fetcher (with labeler-aware cache keys), and
add cached credential getter for ZENTROPI integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add tests for Zentropi signal integration

Test score mapping (all four quadrants), signal class metadata,
disabled info, error handling (401/404 permanent, 5xx transient,
missing subcategory), and correct API call parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Zentropi label type coercion and test credential helper

The Zentropi API returns label as a string ("0"/"1") rather than a
number. The strict equality check (label === 1) would always fail,
causing violating content to receive a safe score. Use Number(label)
to handle both string and number responses.

Also fix test helper makeCredentialGetter to use string | null instead
of string | undefined, since passing undefined explicitly triggers the
JS default parameter value, making the missing-credentials test path
ineffective.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add labeler version IDs to Zentropi integration

Store labeler versions (id + label pairs) in the Zentropi integration config
alongside the API key. The eligibleSubcategories resolver dynamically returns
org-specific labeler versions, and the subcategory picker renders a dropdown
for flat subcategory lists. Also fixes spot test to work on rules regardless
of their enabled status.

* Address PR review feedback: add missing DB enum, use fetchHTTP, bind in cached fetchers

- Add ZENTROPI_LABELER to enum_signals_type in migration SQL
- Replace raw fetch() with shared fetchHTTP dependency in getZentropiScores
- Bind fetchHTTP into getZentropiScores in makeCachedFetchers
- Update tests to mock fetchHTTP instead of global.fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Inline override values in ZentropiLabelerSignal, remove wrapper functions

Move simple return values (docsUrl, integration, pricingStructure, etc.)
directly into the signal class instead of delegating to single-use
functions in zentropiUtils.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Collapse Zentropi migrations into a single file

Merge labeler_versions column into the initial CREATE TABLE instead of
a separate ALTER TABLE migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix lint and betterer CI failures

Use Object.assign instead of property mutation in test helpers to
satisfy better-mutation/no-mutation rule. Replace @ant-design/icons
with lucide-react equivalents in IntegrationConfigApiCredentialsSection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix remaining lint errors in spotTest and signalAuthService

Remove unnecessary null check in spotTest (getGraphQLRuleFromId always
returns or throws). Replace JSON.parse/JSON.stringify with jsonParse/
jsonStringify in signalAuthService per codebase conventions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Bump zentropi migration datestamp to avoid ordering conflict

Main already has a migration at 2026.02.10T00.00.00; rename ours
to 2026.02.18T00.00.00 so it runs after all existing migrations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: samidh <Samidh@host4-129-30-192.springshosting.net>

authored by

samidh
Claude Opus 4.6
samidh
and committed by
GitHub
30e3d576 3f8c425d

+1060 -47
+15
.devops/migrator/src/scripts/api-server-pg/2026.02.18T00.00.00.add_zentropi_signal.sql
··· 1 + -- Add Zentropi signal type and config table 2 + ALTER TYPE public.enum_signals_type ADD VALUE IF NOT EXISTS 'ZENTROPI_LABELER'; 3 + 4 + CREATE TABLE IF NOT EXISTS signal_auth_service.zentropi_configs ( 5 + org_id character varying(255) NOT NULL, 6 + created_at timestamp with time zone DEFAULT now() NOT NULL, 7 + updated_at timestamp with time zone DEFAULT now() NOT NULL, 8 + api_key character varying(255) NOT NULL, 9 + labeler_versions JSONB DEFAULT '[]' 10 + ); 11 + 12 + ALTER TABLE signal_auth_service.zentropi_configs OWNER TO postgres; 13 + 14 + ALTER TABLE ONLY signal_auth_service.zentropi_configs 15 + ADD CONSTRAINT zentropi_configs_pkey PRIMARY KEY (org_id);
+45 -1
client/src/graphql/generated.ts
··· 1251 1251 OpenAi: 'OPEN_AI', 1252 1252 SightEngine: 'SIGHT_ENGINE', 1253 1253 TwoHat: 'TWO_HAT', 1254 + Zentropi: 'ZENTROPI', 1254 1255 } as const; 1255 1256 1256 1257 export type GQLIntegration = 1257 1258 (typeof GQLIntegration)[keyof typeof GQLIntegration]; 1258 1259 export type GQLIntegrationApiCredential = 1259 1260 | GQLGoogleContentSafetyApiIntegrationApiCredential 1260 - | GQLOpenAiIntegrationApiCredential; 1261 + | GQLOpenAiIntegrationApiCredential 1262 + | GQLZentropiIntegrationApiCredential; 1261 1263 1262 1264 export type GQLIntegrationApiCredentialInput = { 1263 1265 readonly googleContentSafetyApi?: InputMaybe<GQLGoogleContentSafetyApiIntegrationApiCredentialInput>; 1264 1266 readonly openAi?: InputMaybe<GQLOpenAiIntegrationApiCredentialInput>; 1267 + readonly zentropi?: InputMaybe<GQLZentropiIntegrationApiCredentialInput>; 1265 1268 }; 1266 1269 1267 1270 export type GQLIntegrationConfig = { ··· 3951 3954 TextSimilarityScore: 'TEXT_SIMILARITY_SCORE', 3952 3955 UserScore: 'USER_SCORE', 3953 3956 UserStrikeValue: 'USER_STRIKE_VALUE', 3957 + ZentropiLabeler: 'ZENTROPI_LABELER', 3954 3958 } as const; 3955 3959 3956 3960 export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; ··· 4663 4667 export type GQLWindowConfigurationInput = { 4664 4668 readonly hopMs: Scalars['Int']; 4665 4669 readonly sizeMs: Scalars['Int']; 4670 + }; 4671 + 4672 + export type GQLZentropiIntegrationApiCredential = { 4673 + readonly __typename: 'ZentropiIntegrationApiCredential'; 4674 + readonly apiKey: Scalars['String']; 4675 + readonly labelerVersions: ReadonlyArray<GQLZentropiLabelerVersion>; 4676 + }; 4677 + 4678 + export type GQLZentropiIntegrationApiCredentialInput = { 4679 + readonly apiKey: Scalars['String']; 4680 + readonly labelerVersions?: InputMaybe< 4681 + ReadonlyArray<GQLZentropiLabelerVersionInput> 4682 + >; 4683 + }; 4684 + 4685 + export type GQLZentropiLabelerVersion = { 4686 + readonly __typename: 'ZentropiLabelerVersion'; 4687 + readonly id: Scalars['String']; 4688 + readonly label: Scalars['String']; 4689 + }; 4690 + 4691 + export type GQLZentropiLabelerVersionInput = { 4692 + readonly id: Scalars['String']; 4693 + readonly label: Scalars['String']; 4666 4694 }; 4667 4695 4668 4696 export type GQLApiAuthQueryVariables = Exact<{ [key: string]: never }>; ··· 5582 5610 | { 5583 5611 readonly __typename: 'OpenAiIntegrationApiCredential'; 5584 5612 readonly apiKey: string; 5613 + } 5614 + | { 5615 + readonly __typename: 'ZentropiIntegrationApiCredential'; 5616 + readonly apiKey: string; 5617 + readonly labelerVersions: ReadonlyArray<{ 5618 + readonly __typename: 'ZentropiLabelerVersion'; 5619 + readonly id: string; 5620 + readonly label: string; 5621 + }>; 5585 5622 }; 5586 5623 } | null; 5587 5624 } ··· 27203 27240 } 27204 27241 ... on OpenAiIntegrationApiCredential { 27205 27242 apiKey 27243 + } 27244 + ... on ZentropiIntegrationApiCredential { 27245 + apiKey 27246 + labelerVersions { 27247 + id 27248 + label 27249 + } 27206 27250 } 27207 27251 } 27208 27252 }
client/src/images/ZentropiLogo.png

This is a binary file and will not be displayed.

+2
client/src/models/signal.ts
··· 60 60 case 'OPEN_AI_VIOLENCE_TEXT_MODEL': 61 61 case 'OPEN_AI_WHISPER_TRANSCRIPTION': 62 62 return GQLIntegration.OpenAi; 63 + case 'ZENTROPI_LABELER': 64 + return GQLIntegration.Zentropi; 63 65 case 'AGGREGATION': 64 66 case 'CUSTOM': 65 67 case 'GEO_CONTAINED_WITHIN':
+93 -1
client/src/webpages/dashboard/integrations/IntegrationConfigApiCredentialsSection.tsx
··· 1 - import { Input } from 'antd'; 1 + import { Button, Input } from 'antd'; 2 + import { Plus, Trash2 } from 'lucide-react'; 2 3 3 4 import { 4 5 GQLGoogleContentSafetyApiIntegrationApiCredential, 5 6 GQLIntegration, 6 7 GQLIntegrationApiCredential, 7 8 GQLOpenAiIntegrationApiCredential, 9 + GQLZentropiIntegrationApiCredential, 8 10 } from '../../../graphql/generated'; 9 11 10 12 export default function IntegrationConfigApiCredentialsSection(props: { ··· 52 54 ); 53 55 }; 54 56 57 + const renderZentropiCredential = ( 58 + apiCredential: GQLZentropiIntegrationApiCredential, 59 + ) => { 60 + const labelerVersions = apiCredential.labelerVersions ?? []; 61 + 62 + const updateLabelerVersion = ( 63 + index: number, 64 + field: 'id' | 'label', 65 + value: string, 66 + ) => { 67 + const updated = labelerVersions.map((v, i) => 68 + i === index ? { ...v, [field]: value } : v, 69 + ); 70 + setApiCredential({ ...apiCredential, labelerVersions: updated }); 71 + }; 72 + 73 + const addLabelerVersion = () => { 74 + setApiCredential({ 75 + ...apiCredential, 76 + labelerVersions: [ 77 + ...labelerVersions, 78 + { __typename: 'ZentropiLabelerVersion' as const, id: '', label: '' }, 79 + ], 80 + }); 81 + }; 82 + 83 + const removeLabelerVersion = (index: number) => { 84 + setApiCredential({ 85 + ...apiCredential, 86 + labelerVersions: labelerVersions.filter((_, i) => i !== index), 87 + }); 88 + }; 89 + 90 + return ( 91 + <div className="flex flex-col gap-4"> 92 + <div className="flex flex-col w-1/2"> 93 + <div className="mb-1">API Key</div> 94 + <Input 95 + value={apiCredential.apiKey} 96 + onChange={(event) => 97 + setApiCredential({ 98 + ...apiCredential, 99 + apiKey: event.target.value, 100 + }) 101 + } 102 + /> 103 + </div> 104 + <div className="flex flex-col w-1/2"> 105 + <div className="mb-2 font-semibold">Labeler Versions</div> 106 + {labelerVersions.map((version, index) => ( 107 + <div key={index} className="flex items-center gap-2 mb-2"> 108 + <Input 109 + placeholder="Version ID" 110 + value={version.id} 111 + onChange={(event) => 112 + updateLabelerVersion(index, 'id', event.target.value) 113 + } 114 + className="flex-1" 115 + /> 116 + <Input 117 + placeholder="Labeler Name" 118 + value={version.label} 119 + onChange={(event) => 120 + updateLabelerVersion(index, 'label', event.target.value) 121 + } 122 + className="flex-1" 123 + /> 124 + <Button 125 + type="text" 126 + icon={<Trash2 size={14} />} 127 + onClick={() => removeLabelerVersion(index)} 128 + danger 129 + /> 130 + </div> 131 + ))} 132 + <Button 133 + type="dashed" 134 + icon={<Plus size={14} />} 135 + onClick={addLabelerVersion} 136 + className="w-fit" 137 + > 138 + Add Labeler Version 139 + </Button> 140 + </div> 141 + </div> 142 + ); 143 + }; 144 + 55 145 const projectKeysInput = () => { 56 146 switch (apiCredential.__typename) { 57 147 case 'GoogleContentSafetyApiIntegrationApiCredential': 58 148 return renderGoogleContentSafetyApiCredential(apiCredential); 59 149 case 'OpenAiIntegrationApiCredential': 60 150 return renderOpenAiCredential(apiCredential); 151 + case 'ZentropiIntegrationApiCredential': 152 + return renderZentropiCredential(apiCredential); 61 153 default: 62 154 throw new Error('Integration not implemented yet'); 63 155 }
+27
client/src/webpages/dashboard/integrations/IntegrationConfigForm.tsx
··· 18 18 useGQLSetIntegrationConfigMutation, 19 19 type GQLGoogleContentSafetyApiIntegrationApiCredential, 20 20 type GQLOpenAiIntegrationApiCredential, 21 + type GQLZentropiIntegrationApiCredential, 21 22 } from '../../../graphql/generated'; 22 23 import { 23 24 stripTypename, ··· 59 60 ... on OpenAiIntegrationApiCredential { 60 61 apiKey 61 62 } 63 + ... on ZentropiIntegrationApiCredential { 64 + apiKey 65 + labelerVersions { 66 + id 67 + label 68 + } 69 + } 62 70 } 63 71 } 64 72 } ··· 92 100 case 'OPEN_AI': { 93 101 return { __typename: 'OpenAiIntegrationApiCredential', apiKey: '' }; 94 102 } 103 + case 'ZENTROPI': { 104 + return { 105 + __typename: 'ZentropiIntegrationApiCredential', 106 + apiKey: '', 107 + labelerVersions: [], 108 + }; 109 + } 95 110 default: { 96 111 throw new Error(`${name} integration not implemented.`); 97 112 } ··· 181 196 const mappedApiCredential = taggedUnionToOneOfInput(apiCredential, { 182 197 GoogleContentSafetyApiIntegrationApiCredential: 'googleContentSafetyApi', 183 198 OpenAiIntegrationApiCredential: 'openAi', 199 + ZentropiIntegrationApiCredential: 'zentropi', 184 200 }); 185 201 186 202 const validationMessage = (() => { ··· 201 217 .apiKey 202 218 ) { 203 219 return 'Please input the OpenAI API key'; 220 + } 221 + 222 + if ( 223 + 'zentropi' in mappedApiCredential && 224 + !( 225 + mappedApiCredential[ 226 + 'zentropi' 227 + ] as GQLZentropiIntegrationApiCredential 228 + ).apiKey 229 + ) { 230 + return 'Please input the Zentropi API key'; 204 231 } 205 232 206 233 return undefined;
+9
client/src/webpages/dashboard/integrations/integrationConfigs.ts
··· 3 3 import GoogleLogoWithBackground from '../../../images/GoogleLogoWithBackground.png'; 4 4 import OpenAILogo from '../../../images/OpenAILogo.png'; 5 5 import OpenAILogoWithBackground from '../../../images/OpenAILogoWithBackground.png'; 6 + import ZentropiLogo from '../../../images/ZentropiLogo.png'; 6 7 import { IntegrationConfig } from './IntegrationsDashboard'; 7 8 8 9 export const INTEGRATION_CONFIGS: IntegrationConfig[] = [ ··· 20 21 logo: OpenAILogo, 21 22 logoWithBackground: OpenAILogoWithBackground, 22 23 url: 'https://openai.com/', 24 + requiresInfo: true, 25 + }, 26 + { 27 + name: GQLIntegration.Zentropi, 28 + title: 'Zentropi', 29 + logo: ZentropiLogo, 30 + logoWithBackground: ZentropiLogo, 31 + url: 'https://docs.zentropi.ai', 23 32 requiresInfo: true, 24 33 }, 25 34 ];
+41 -16
client/src/webpages/dashboard/rules/rule_form/signal_modal/RuleFormSignalModalSubcategoryGallery.tsx
··· 1 1 import { SearchOutlined } from '@ant-design/icons'; 2 - import { Input } from 'antd'; 2 + import { Input, Select } from 'antd'; 3 3 import omit from 'lodash/omit'; 4 4 import { useState } from 'react'; 5 5 ··· 17 17 const { subcategories, onSelectSubcategoryOption } = props; 18 18 const [searchTerm, setSearchTerm] = useState<string>(''); 19 19 20 + const stripped = subcategories.map((subcategory) => 21 + omit(subcategory, '__typename'), 22 + ); 23 + 24 + // Check if subcategories are flat (no parent-child tree structure). 25 + // Flat subcategories have no childrenIds, so the tree rebuild filters them all out. 26 + const hasTreeStructure = stripped.some((s) => s.childrenIds.length > 0); 27 + 28 + if (!hasTreeStructure && stripped.length > 0) { 29 + // Render a simple dropdown for flat subcategories (e.g. Zentropi labeler versions) 30 + return ( 31 + <div className="flex flex-col"> 32 + <div className="pb-3 text-2xl font-medium">Select Subcategory</div> 33 + <Select 34 + className="max-w-xs" 35 + placeholder="Select a labeler version" 36 + onChange={(value: string) => onSelectSubcategoryOption(value)} 37 + options={stripped.map((s) => ({ 38 + value: s.id, 39 + label: s.label, 40 + }))} 41 + /> 42 + </div> 43 + ); 44 + } 45 + 20 46 // Hive subcategories are snake_case, but we display them like this: "Snake Case". 21 47 // So we need to allow a search term like "snake case" match against the subcategory 22 48 // "snake_case". To do this, we add a snake case search term. 23 49 const snakeCaseSearchTerm = searchTerm.replaceAll('_', ' '); 24 - const eligibleSubcategories = rebuildSubcategoryTreeFromGraphQLResponse( 25 - subcategories.map((subcategory) => omit(subcategory, '__typename')), 26 - ) 27 - // First filter out subcategories that don't include the search term. 28 - // eslint-disable-next-line array-callback-return 29 - .filter( 30 - (subcategory) => 31 - subcategory.id.includes(searchTerm) || 32 - subcategory.id.includes(snakeCaseSearchTerm) || 33 - subcategory.label.includes(searchTerm) || 34 - subcategory.label.includes(snakeCaseSearchTerm) || 35 - (subcategory.description && 36 - (subcategory.description.includes(searchTerm) || 37 - subcategory.description.includes(snakeCaseSearchTerm))), 38 - ); 50 + const eligibleSubcategories = 51 + rebuildSubcategoryTreeFromGraphQLResponse(stripped) 52 + // First filter out subcategories that don't include the search term. 53 + // eslint-disable-next-line array-callback-return 54 + .filter( 55 + (subcategory) => 56 + subcategory.id.includes(searchTerm) || 57 + subcategory.id.includes(snakeCaseSearchTerm) || 58 + subcategory.label.includes(searchTerm) || 59 + subcategory.label.includes(snakeCaseSearchTerm) || 60 + (subcategory.description && 61 + (subcategory.description.includes(searchTerm) || 62 + subcategory.description.includes(snakeCaseSearchTerm))), 63 + ); 39 64 40 65 return ( 41 66 <div className="flex flex-col">
+11
server/graphql/datasources/IntegrationApi.ts
··· 57 57 ); 58 58 } 59 59 60 + if (apiCredential.zentropi) { 61 + return this.__private__setConfig( 62 + 'ZENTROPI', 63 + { 64 + apiKey: apiCredential.zentropi.apiKey, 65 + labelerVersions: [...(apiCredential.zentropi.labelerVersions ?? [])], 66 + }, 67 + orgId, 68 + ); 69 + } 70 + 60 71 throw new Error('No credentials provided'); 61 72 } 62 73
+69 -4
server/graphql/generated.ts
··· 1320 1320 OpenAi: 'OPEN_AI', 1321 1321 SightEngine: 'SIGHT_ENGINE', 1322 1322 TwoHat: 'TWO_HAT', 1323 + Zentropi: 'ZENTROPI', 1323 1324 } as const; 1324 1325 1325 1326 export type GQLIntegration = 1326 1327 (typeof GQLIntegration)[keyof typeof GQLIntegration]; 1327 1328 export type GQLIntegrationApiCredential = 1328 1329 | GQLGoogleContentSafetyApiIntegrationApiCredential 1329 - | GQLOpenAiIntegrationApiCredential; 1330 + | GQLOpenAiIntegrationApiCredential 1331 + | GQLZentropiIntegrationApiCredential; 1330 1332 1331 1333 export type GQLIntegrationApiCredentialInput = { 1332 1334 readonly googleContentSafetyApi?: InputMaybe<GQLGoogleContentSafetyApiIntegrationApiCredentialInput>; 1333 1335 readonly openAi?: InputMaybe<GQLOpenAiIntegrationApiCredentialInput>; 1336 + readonly zentropi?: InputMaybe<GQLZentropiIntegrationApiCredentialInput>; 1334 1337 }; 1335 1338 1336 1339 export type GQLIntegrationConfig = { ··· 4020 4023 TextSimilarityScore: 'TEXT_SIMILARITY_SCORE', 4021 4024 UserScore: 'USER_SCORE', 4022 4025 UserStrikeValue: 'USER_STRIKE_VALUE', 4026 + ZentropiLabeler: 'ZENTROPI_LABELER', 4023 4027 } as const; 4024 4028 4025 4029 export type GQLSignalType = (typeof GQLSignalType)[keyof typeof GQLSignalType]; ··· 4734 4738 readonly sizeMs: Scalars['Int']; 4735 4739 }; 4736 4740 4741 + export type GQLZentropiIntegrationApiCredential = { 4742 + readonly __typename?: 'ZentropiIntegrationApiCredential'; 4743 + readonly apiKey: Scalars['String']; 4744 + readonly labelerVersions: ReadonlyArray<GQLZentropiLabelerVersion>; 4745 + }; 4746 + 4747 + export type GQLZentropiIntegrationApiCredentialInput = { 4748 + readonly apiKey: Scalars['String']; 4749 + readonly labelerVersions?: InputMaybe< 4750 + ReadonlyArray<GQLZentropiLabelerVersionInput> 4751 + >; 4752 + }; 4753 + 4754 + export type GQLZentropiLabelerVersion = { 4755 + readonly __typename?: 'ZentropiLabelerVersion'; 4756 + readonly id: Scalars['String']; 4757 + readonly label: Scalars['String']; 4758 + }; 4759 + 4760 + export type GQLZentropiLabelerVersionInput = { 4761 + readonly id: Scalars['String']; 4762 + readonly label: Scalars['String']; 4763 + }; 4764 + 4737 4765 export type ResolverTypeWrapper<T> = Promise<T> | T; 4738 4766 4739 4767 export type ResolverWithResolve<TResult, TParent, TContext, TArgs> = { ··· 5120 5148 Integration: GQLIntegration; 5121 5149 IntegrationApiCredential: 5122 5150 | GQLResolversTypes['GoogleContentSafetyApiIntegrationApiCredential'] 5123 - | GQLResolversTypes['OpenAiIntegrationApiCredential']; 5151 + | GQLResolversTypes['OpenAiIntegrationApiCredential'] 5152 + | GQLResolversTypes['ZentropiIntegrationApiCredential']; 5124 5153 IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; 5125 5154 IntegrationConfig: ResolverTypeWrapper< 5126 5155 Omit<GQLIntegrationConfig, 'apiCredential'> & { ··· 5703 5732 ValueComparator: GQLValueComparator; 5704 5733 WindowConfiguration: ResolverTypeWrapper<GQLWindowConfiguration>; 5705 5734 WindowConfigurationInput: GQLWindowConfigurationInput; 5735 + ZentropiIntegrationApiCredential: ResolverTypeWrapper<GQLZentropiIntegrationApiCredential>; 5736 + ZentropiIntegrationApiCredentialInput: GQLZentropiIntegrationApiCredentialInput; 5737 + ZentropiLabelerVersion: ResolverTypeWrapper<GQLZentropiLabelerVersion>; 5738 + ZentropiLabelerVersionInput: GQLZentropiLabelerVersionInput; 5706 5739 }; 5707 5740 5708 5741 /** Mapping between all available schema types and the resolvers parents */ ··· 5955 5988 Int: Scalars['Int']; 5956 5989 IntegrationApiCredential: 5957 5990 | GQLResolversParentTypes['GoogleContentSafetyApiIntegrationApiCredential'] 5958 - | GQLResolversParentTypes['OpenAiIntegrationApiCredential']; 5991 + | GQLResolversParentTypes['OpenAiIntegrationApiCredential'] 5992 + | GQLResolversParentTypes['ZentropiIntegrationApiCredential']; 5959 5993 IntegrationApiCredentialInput: GQLIntegrationApiCredentialInput; 5960 5994 IntegrationConfig: Omit<GQLIntegrationConfig, 'apiCredential'> & { 5961 5995 apiCredential: GQLResolversParentTypes['IntegrationApiCredential']; ··· 6462 6496 UserSubmissionsHistory: GQLUserSubmissionsHistory; 6463 6497 WindowConfiguration: GQLWindowConfiguration; 6464 6498 WindowConfigurationInput: GQLWindowConfigurationInput; 6499 + ZentropiIntegrationApiCredential: GQLZentropiIntegrationApiCredential; 6500 + ZentropiIntegrationApiCredentialInput: GQLZentropiIntegrationApiCredentialInput; 6501 + ZentropiLabelerVersion: GQLZentropiLabelerVersion; 6502 + ZentropiLabelerVersionInput: GQLZentropiLabelerVersionInput; 6465 6503 }; 6466 6504 6467 6505 export type GQLPublicResolverDirectiveArgs = {}; ··· 8357 8395 > = { 8358 8396 __resolveType: TypeResolveFn< 8359 8397 | 'GoogleContentSafetyApiIntegrationApiCredential' 8360 - | 'OpenAiIntegrationApiCredential', 8398 + | 'OpenAiIntegrationApiCredential' 8399 + | 'ZentropiIntegrationApiCredential', 8361 8400 ParentType, 8362 8401 ContextType 8363 8402 >; ··· 13677 13716 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 13678 13717 }; 13679 13718 13719 + export type GQLZentropiIntegrationApiCredentialResolvers< 13720 + ContextType = Context, 13721 + ParentType extends 13722 + GQLResolversParentTypes['ZentropiIntegrationApiCredential'] = GQLResolversParentTypes['ZentropiIntegrationApiCredential'], 13723 + > = { 13724 + apiKey?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 13725 + labelerVersions?: Resolver< 13726 + ReadonlyArray<GQLResolversTypes['ZentropiLabelerVersion']>, 13727 + ParentType, 13728 + ContextType 13729 + >; 13730 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 13731 + }; 13732 + 13733 + export type GQLZentropiLabelerVersionResolvers< 13734 + ContextType = Context, 13735 + ParentType extends 13736 + GQLResolversParentTypes['ZentropiLabelerVersion'] = GQLResolversParentTypes['ZentropiLabelerVersion'], 13737 + > = { 13738 + id?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 13739 + label?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 13740 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 13741 + }; 13742 + 13680 13743 export type GQLResolvers<ContextType = Context> = { 13681 13744 AcceptAppealDecisionComponent?: GQLAcceptAppealDecisionComponentResolvers<ContextType>; 13682 13745 Action?: GQLActionResolvers<ContextType>; ··· 13986 14049 UserSubmissionCount?: GQLUserSubmissionCountResolvers<ContextType>; 13987 14050 UserSubmissionsHistory?: GQLUserSubmissionsHistoryResolvers<ContextType>; 13988 14051 WindowConfiguration?: GQLWindowConfigurationResolvers<ContextType>; 14052 + ZentropiIntegrationApiCredential?: GQLZentropiIntegrationApiCredentialResolvers<ContextType>; 14053 + ZentropiLabelerVersion?: GQLZentropiLabelerVersionResolvers<ContextType>; 13989 14054 }; 13990 14055 13991 14056 export type GQLDirectiveResolvers<ContextType = Context> = {
+31 -5
server/graphql/modules/integration.ts
··· 1 1 import { AuthenticationError } from 'apollo-server-express'; 2 2 3 - import { 4 - isConfigurableIntegration, 5 - } from '../../services/signalAuthService/index.js'; 3 + import { isConfigurableIntegration } from '../../services/signalAuthService/index.js'; 6 4 import { Integration } from '../../services/signalsService/index.js'; 7 5 import { isCoopErrorOfType } from '../../utils/errors.js'; 6 + import { assertUnreachable } from '../../utils/misc.js'; 8 7 import { 9 8 makeIntegrationConfigUnsupportedIntegrationError, 10 9 type TIntegrationCredential, ··· 15 14 } from '../generated.js'; 16 15 import { type ResolverMap } from '../resolvers.js'; 17 16 import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 18 - import { assertUnreachable } from '../../utils/misc.js'; 19 17 20 18 const typeDefs = /* GraphQL */ ` 21 19 enum Integration { ··· 27 25 OPEN_AI 28 26 SIGHT_ENGINE 29 27 TWO_HAT 28 + ZENTROPI 30 29 } 31 30 32 31 type GoogleContentSafetyApiIntegrationApiCredential { ··· 37 36 apiKey: String! 38 37 } 39 38 39 + type ZentropiLabelerVersion { 40 + id: String! 41 + label: String! 42 + } 43 + 44 + type ZentropiIntegrationApiCredential { 45 + apiKey: String! 46 + labelerVersions: [ZentropiLabelerVersion!]! 47 + } 48 + 40 49 union IntegrationApiCredential = 41 50 GoogleContentSafetyApiIntegrationApiCredential 42 51 | OpenAiIntegrationApiCredential 52 + | ZentropiIntegrationApiCredential 43 53 44 54 type IntegrationConfig { 45 55 name: Integration! ··· 54 64 apiKey: String! 55 65 } 56 66 67 + input ZentropiLabelerVersionInput { 68 + id: String! 69 + label: String! 70 + } 71 + 72 + input ZentropiIntegrationApiCredentialInput { 73 + apiKey: String! 74 + labelerVersions: [ZentropiLabelerVersionInput!] 75 + } 76 + 57 77 input IntegrationApiCredentialInput { 58 78 googleContentSafetyApi: GoogleContentSafetyApiIntegrationApiCredentialInput 59 79 openAi: OpenAiIntegrationApiCredentialInput 80 + zentropi: ZentropiIntegrationApiCredentialInput 60 81 } 61 82 62 83 input SetIntegrationConfigInput { ··· 136 157 return 'GoogleContentSafetyApiIntegrationApiCredential'; 137 158 case Integration.OPEN_AI: 138 159 return 'OpenAiIntegrationApiCredential'; 160 + case Integration.ZENTROPI: 161 + return 'ZentropiIntegrationApiCredential'; 139 162 default: 140 163 // TypeScript can't verify exhaustiveness here because GQL enum includes 141 - assertUnreachable(integrationName, `Unsupported integration: ${integrationName}`); 164 + assertUnreachable( 165 + integrationName, 166 + `Unsupported integration: ${integrationName}`, 167 + ); 142 168 } 143 169 }, 144 170 };
+20 -2
server/graphql/modules/signal.ts
··· 96 96 OPEN_AI_SEXUAL_TEXT_MODEL 97 97 OPEN_AI_VIOLENCE_TEXT_MODEL 98 98 OPEN_AI_WHISPER_TRANSCRIPTION 99 + ZENTROPI_LABELER 99 100 GEO_CONTAINED_WITHIN 100 101 USER_SCORE 101 102 USER_STRIKE_VALUE ··· 162 163 enum AggregationType { 163 164 COUNT 164 165 } 165 - 166 166 `; 167 167 168 168 const SignalOutputType: ResolverMap<TSignalOutputType> = { ··· 227 227 return { languages: supportedLanguages satisfies readonly GQLLanguage[] }; 228 228 } 229 229 }, 230 - eligibleSubcategories(signal) { 230 + async eligibleSubcategories(signal, _, context) { 231 + // For Zentropi signals, return org-specific labeler versions as subcategories 232 + if (signal.id.type === 'ZENTROPI_LABELER') { 233 + const user = context.getUser(); 234 + if (user) { 235 + const config = await context.dataSources.integrationAPI.getConfig( 236 + user.orgId, 237 + 'ZENTROPI', 238 + ); 239 + if (config?.name === 'ZENTROPI') { 240 + const versions = config.apiCredential.labelerVersions ?? []; 241 + return versions.map((v) => ({ 242 + id: v.id, 243 + label: v.label, 244 + childrenIds: [], 245 + })); 246 + } 247 + } 248 + } 231 249 return flattenSubcategories(signal.eligibleSubcategories); 232 250 }, 233 251 shouldPromptForMatchingValues(signal) {
+4 -10
server/graphql/modules/spotTest.ts
··· 18 18 `; 19 19 20 20 const Query: GQLQueryResolvers = { 21 - async spotTestRule(_, { ruleId, item }, { services, getUser }) { 21 + async spotTestRule(_, { ruleId, item }, { services, dataSources, getUser }) { 22 22 const user = getUser(); 23 23 if (user == null) { 24 24 throw new AuthenticationError('Authenticated user required'); 25 25 } 26 26 27 - const [itemTypes, enabledRules] = await Promise.all([ 28 - await services.ModerationConfigService.getItemTypes({ 27 + const [itemTypes, rule] = await Promise.all([ 28 + services.ModerationConfigService.getItemTypes({ 29 29 orgId: user.orgId, 30 30 directives: { maxAge: 10 }, 31 31 }), 32 - await services.getEnabledRulesForItemTypeEventuallyConsistent( 33 - item.itemTypeIdentifier.id, 34 - ), 32 + dataSources.ruleAPI.getGraphQLRuleFromId(ruleId, user.orgId), 35 33 ]); 36 34 const itemType = itemTypes.find( 37 35 (it) => it.id === item.itemTypeIdentifier.id, ··· 64 62 orgId: user.orgId, 65 63 input: itemSubmissionOrErrors.itemSubmission, 66 64 }); 67 - const rule = enabledRules?.find((rule) => rule.id === ruleId); 68 - if (!rule) { 69 - throw new Error('Could not find rule'); 70 - } 71 65 const result = await services.RuleEvaluator.runRule( 72 66 rule.conditionSet, 73 67 executionContext,
+7
server/services/signalAuthService/dbTypes.ts
··· 13 13 created_at: ColumnType<Date, never, never>; 14 14 updated_at: ColumnType<Date, never, never>; 15 15 }; 16 + 'signal_auth_service.zentropi_configs': { 17 + org_id: string; 18 + api_key: string; 19 + labeler_versions: ColumnType<string, string | undefined, string | undefined>; 20 + created_at: ColumnType<Date, never, never>; 21 + updated_at: ColumnType<Date, never, never>; 22 + }; 16 23 };
+65 -1
server/services/signalAuthService/signalAuthService.ts
··· 3 3 4 4 import { inject } from '../../iocContainer/utils.js'; 5 5 import { type Cached } from '../../utils/caching.js'; 6 + import { type JsonOf } from '../../utils/encoding.js'; 7 + import { jsonParse, jsonStringify } from '../../utils/encoding.js'; 6 8 import { type NonEmptyString } from '../../utils/typescript-types.js'; 7 9 import { Integration } from '../signalsService/index.js'; 8 10 import { type SignalAuthServicePg } from './dbTypes.js'; ··· 24 26 25 27 export type GoogleContentSafetyCredential = { apiKey: string }; 26 28 export type OpenAICredential = { apiKey: string }; 29 + export type ZentropiLabelerVersion = { id: string; label: string }; 30 + export type ZentropiCredential = { 31 + apiKey: string; 32 + labelerVersions?: ZentropiLabelerVersion[]; 33 + }; 27 34 export type ClarifaiApiCredential = { apiKey: NonEmptyString }; 28 35 export type ClarifaiModelType = 'IMAGE' | 'TEXT'; 29 36 export type ClarifaiPATCredential = { ··· 38 45 export type CredentialTypes = { 39 46 [Integration.GOOGLE_CONTENT_SAFETY_API]: GoogleContentSafetyCredential; 40 47 [Integration.OPEN_AI]: OpenAICredential; 48 + [Integration.ZENTROPI]: ZentropiCredential; 41 49 }; 42 50 43 51 // Both our internal Integration enum and the external GQL enum include some ··· 49 57 export const configurableIntegrations = [ 50 58 Integration.GOOGLE_CONTENT_SAFETY_API, 51 59 Integration.OPEN_AI, 60 + Integration.ZENTROPI, 52 61 ] as const; 53 62 54 63 /** ··· 179 188 .executeTakeFirst(); 180 189 }, 181 190 }, 182 - 191 + [Integration.ZENTROPI]: { 192 + get: async (orgId: string) => { 193 + const row = await pg 194 + .selectFrom('signal_auth_service.zentropi_configs') 195 + .select(['api_key', 'labeler_versions']) 196 + .where('org_id', '=', orgId) 197 + .executeTakeFirst(); 198 + if (row == null) return undefined; 199 + const labelerVersions = row.labeler_versions; 200 + return { 201 + apiKey: row.api_key, 202 + labelerVersions: Array.isArray(labelerVersions) 203 + ? (labelerVersions as ZentropiLabelerVersion[]) 204 + : typeof labelerVersions === 'string' 205 + ? (jsonParse(labelerVersions as JsonOf<ZentropiLabelerVersion[]>)) 206 + : [], 207 + }; 208 + }, 209 + set: async (orgId: string, credential: ZentropiCredential) => { 210 + const labelerVersionsJson = jsonStringify( 211 + credential.labelerVersions ?? [], 212 + ); 213 + const row = await pg 214 + .insertInto('signal_auth_service.zentropi_configs') 215 + .values([ 216 + { 217 + org_id: orgId, 218 + api_key: credential.apiKey, 219 + labeler_versions: labelerVersionsJson, 220 + }, 221 + ]) 222 + .onConflict((oc) => 223 + oc.column('org_id').doUpdateSet({ 224 + api_key: credential.apiKey, 225 + labeler_versions: labelerVersionsJson, 226 + }), 227 + ) 228 + .returning(['api_key', 'labeler_versions']) 229 + .executeTakeFirstOrThrow(); 230 + const returnedVersions = row.labeler_versions; 231 + return { 232 + apiKey: row.api_key, 233 + labelerVersions: Array.isArray(returnedVersions) 234 + ? (returnedVersions as ZentropiLabelerVersion[]) 235 + : typeof returnedVersions === 'string' 236 + ? (jsonParse(returnedVersions as JsonOf<ZentropiLabelerVersion[]>)) 237 + : [], 238 + }; 239 + }, 240 + delete: async (orgId: string) => { 241 + await pg 242 + .deleteFrom('signal_auth_service.zentropi_configs') 243 + .where('org_id', '=', orgId) 244 + .executeTakeFirst(); 245 + }, 246 + }, 183 247 }; 184 248 }
+11 -6
server/services/signalsService/helpers/instantiateBuiltInSignals.ts
··· 2 2 import { type ItemIdentifier } from '@roostorg/types'; 3 3 4 4 import type { AggregationsService } from '../../aggregationsService/index.js'; 5 + import type { HmaService } from '../../hmaService/index.js'; 5 6 import type { GetPoliciesByIdEventuallyConsistent } from '../../manualReviewToolService/manualReviewToolQueries.js'; 6 7 import { type UserScore } from '../../userStatisticsService/userStatisticsService.js'; 7 8 import { type UserStrikeService } from '../../userStrikeService/index.js'; 8 9 import AggregationSignal from '../signals/aggregation/AggregationSignal.js'; 10 + import CoopRiskModelSignal from '../signals/CoopRiskModelSignal.js'; 9 11 import GeoContainedWithinSignal from '../signals/GeoContainedWithinSignal.js'; 10 12 import ImageExactMatchSignal from '../signals/ImageExactMatchSignal.js'; 11 - import ImageSimilarityScoreSignal from '../signals/ImageSimilarityScoreSignal.js'; 12 13 import ImageSimilarityDoesNotMatchSignal from '../signals/ImageSimilarityDoesNotMatch.js'; 13 14 import ImageSimilarityMatchSignal from '../signals/ImageSimilarityMatch.js'; 14 - import CoopRiskModelSignal from '../signals/CoopRiskModelSignal.js'; 15 + import ImageSimilarityScoreSignal from '../signals/ImageSimilarityScoreSignal.js'; 15 16 import { 16 17 type SignalBase, 17 18 type SignalInputType, ··· 32 33 import OpenAiSexualTextSignal from '../signals/third_party_signals/open_ai/moderation/OpenAiSexualTextSignal.js'; 33 34 import OpenAiViolenceTextSignal from '../signals/third_party_signals/open_ai/moderation/OpenAiViolenceTextSignal.js'; 34 35 import OpenAiWhisperTranscriptionSignal from '../signals/third_party_signals/open_ai/whisper/OpenAiWhisperTranscriptionSignal.js'; 36 + import ZentropiLabelerSignal from '../signals/third_party_signals/zentropi/ZentropiLabelerSignal.js'; 35 37 import UserScoreSignal from '../signals/UserScoreSignal.js'; 36 38 import UserStrikesSignal from '../signals/UserStrikesSignal.js'; 37 39 import { SignalType, type BuiltInSignalType } from '../types/SignalType.js'; 38 40 import { type CredentialGetters } from './makeCachedCredentialsGetters.js'; 39 41 import { type CachedFetchers } from './makeCachedFetchers.js'; 40 - import type { HmaService } from '../../hmaService/index.js'; 41 42 42 43 export function instantiateBuiltInSignals( 43 44 credentialGetters: CredentialGetters, ··· 55 56 googleContentSafetyFetcher: getGoogleContentSafetyScores, 56 57 openAiModerationFetcher: getOpenAiScores, 57 58 openAiWhisperTranscriptionFetcher: getOpenAiTranscription, 59 + zentropiFetcher: getZentropiScores, 58 60 } = cachedFetchers; 59 61 60 62 return { ··· 71 73 [SignalType.TEXT_SIMILARITY_SCORE]: new TextSimilarityScoreSignal(), 72 74 [SignalType.IMAGE_EXACT_MATCH]: new ImageExactMatchSignal(), 73 75 [SignalType.IMAGE_SIMILARITY_SCORE]: new ImageSimilarityScoreSignal(), 74 - [SignalType.IMAGE_SIMILARITY_DOES_NOT_MATCH]: new ImageSimilarityDoesNotMatchSignal( 75 - hmaService, 76 - ), 76 + [SignalType.IMAGE_SIMILARITY_DOES_NOT_MATCH]: 77 + new ImageSimilarityDoesNotMatchSignal(hmaService), 77 78 [SignalType.IMAGE_SIMILARITY_MATCH]: new ImageSimilarityMatchSignal( 78 79 hmaService, 79 80 ), ··· 127 128 new GoogleCloudTranslationAPISignal(), 128 129 [SignalType.BENIGN_MODEL]: new CoopRiskModelSignal(), 129 130 [SignalType.AGGREGATION]: new AggregationSignal(aggregationsService), 131 + [SignalType.ZENTROPI_LABELER]: new ZentropiLabelerSignal( 132 + credentialGetters.ZENTROPI, 133 + getZentropiScores, 134 + ), 130 135 // Satisfies check to make sure we didn't forget any signals. 131 136 } satisfies { [K in BuiltInSignalType]: SignalBase<SignalInputType> }; 132 137 }
+4 -1
server/services/signalsService/helpers/makeCachedCredentialsGetters.ts
··· 22 22 }); 23 23 24 24 return { 25 - GOOGLE_CONTENT_SAFETY_API: getApiCredentialForIntegration('GOOGLE_CONTENT_SAFETY_API'), 25 + GOOGLE_CONTENT_SAFETY_API: getApiCredentialForIntegration( 26 + 'GOOGLE_CONTENT_SAFETY_API', 27 + ), 26 28 OPEN_AI: getApiCredentialForIntegration('OPEN_AI'), 29 + ZENTROPI: getApiCredentialForIntegration('ZENTROPI'), 27 30 }; 28 31 }
+2
server/services/signalsService/helpers/makeCachedFetchers.ts
··· 12 12 import { getGoogleContentSafetyScores } from '../signals/third_party_signals/google/content_safety/googleContentSafetyLib.js'; 13 13 import { getOpenAiModerationScores } from '../signals/third_party_signals/open_ai/moderation/openAIModerationUtils.js'; 14 14 import { getOpenAiTranscription } from '../signals/third_party_signals/open_ai/whisper/OpenAiWhisperTranscriptionSignal.js'; 15 + import { getZentropiScores } from '../signals/third_party_signals/zentropi/zentropiUtils.js'; 15 16 16 17 export type CachedFetchers = ReturnType<typeof makeCachedFetchers>; 17 18 ··· 40 41 openAiWhisperTranscriptionFetcher: toCachedFetcher( 41 42 getOpenAiTranscription.bind(null, fetchHTTP), 42 43 ), 44 + zentropiFetcher: toCachedFetcher(getZentropiScores.bind(null, fetchHTTP)), 43 45 }; 44 46 } 45 47
+95
server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.test.ts
··· 1 + import { ScalarTypes } from '@roostorg/types'; 2 + 3 + import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; 4 + import { Integration } from '../../../types/Integration.js'; 5 + import { SignalType } from '../../../types/SignalType.js'; 6 + import { type SignalInput } from '../../SignalBase.js'; 7 + import ZentropiLabelerSignal from './ZentropiLabelerSignal.js'; 8 + import { 9 + type FetchZentropiScores, 10 + type ZentropiResponse, 11 + } from './zentropiUtils.js'; 12 + 13 + type StringSignalInput = SignalInput<ScalarTypes['STRING']>; 14 + 15 + function makeCredentialGetter( 16 + apiKey: string | null = 'test-api-key', 17 + ): CachedGetCredentials<'ZENTROPI'> { 18 + return Object.assign( 19 + jest 20 + .fn() 21 + .mockResolvedValue( 22 + apiKey ? { apiKey } : undefined, 23 + ) as unknown as CachedGetCredentials<'ZENTROPI'>, 24 + { close: jest.fn().mockResolvedValue(undefined) }, 25 + ); 26 + } 27 + 28 + function makeInput( 29 + overrides: Partial<StringSignalInput> = {}, 30 + ): StringSignalInput { 31 + return { 32 + value: { type: 'STRING', value: 'test content' }, 33 + matchingValues: undefined, 34 + actionPenalties: undefined, 35 + orgId: 'org-1', 36 + subcategory: 'lv_abc123', 37 + ...overrides, 38 + } as unknown as StringSignalInput; 39 + } 40 + 41 + describe('ZentropiLabelerSignal', () => { 42 + it('has correct signal metadata', () => { 43 + const signal = new ZentropiLabelerSignal(makeCredentialGetter(), jest.fn()); 44 + 45 + expect(signal.id).toEqual({ type: SignalType.ZENTROPI_LABELER }); 46 + expect(signal.displayName).toBe('Zentropi Labeler'); 47 + expect(signal.integration).toBe(Integration.ZENTROPI); 48 + expect(signal.eligibleInputs).toEqual([ScalarTypes.STRING]); 49 + expect(signal.outputType).toEqual({ scalarType: ScalarTypes.NUMBER }); 50 + expect(signal.allowedInAutomatedRules).toBe(true); 51 + expect(signal.needsMatchingValues).toBe(false); 52 + expect(signal.needsActionPenalties).toBe(false); 53 + expect(signal.eligibleSubcategories).toEqual([]); 54 + expect(signal.supportedLanguages).toBe('ALL'); 55 + expect(signal.getCost()).toBe(20); 56 + }); 57 + 58 + it('returns disabled info when credentials are missing', async () => { 59 + const signal = new ZentropiLabelerSignal( 60 + makeCredentialGetter(null), 61 + jest.fn(), 62 + ); 63 + 64 + const info = await signal.getDisabledInfo('org-1'); 65 + expect(info.disabled).toBe(true); 66 + expect(info.disabledMessage).toContain('Zentropi API key'); 67 + }); 68 + 69 + it('returns enabled info when credentials are present', async () => { 70 + const signal = new ZentropiLabelerSignal( 71 + makeCredentialGetter('key'), 72 + jest.fn(), 73 + ); 74 + 75 + const info = await signal.getDisabledInfo('org-1'); 76 + expect(info.disabled).toBe(false); 77 + }); 78 + 79 + it('calls run and returns correct result', async () => { 80 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 81 + label: 1, 82 + confidence: 0.88, 83 + } satisfies ZentropiResponse); 84 + 85 + const signal = new ZentropiLabelerSignal( 86 + makeCredentialGetter(), 87 + fetchScores, 88 + ); 89 + 90 + const result = await signal.run(makeInput()); 91 + 92 + expect(result.score).toBe(0.88); 93 + expect(result.outputType).toEqual({ scalarType: ScalarTypes.NUMBER }); 94 + }); 95 + });
+113
server/services/signalsService/signals/third_party_signals/zentropi/ZentropiLabelerSignal.ts
··· 1 + import { ScalarTypes } from '@roostorg/types'; 2 + 3 + import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; 4 + import { Integration } from '../../../types/Integration.js'; 5 + import { SignalPricingStructure } from '../../../types/SignalPricingStructure.js'; 6 + import { SignalType } from '../../../types/SignalType.js'; 7 + import SignalBase, { type SignalInput } from '../../SignalBase.js'; 8 + import { 9 + runZentropiLabelerImpl, 10 + type FetchZentropiScores, 11 + } from './zentropiUtils.js'; 12 + 13 + export default class ZentropiLabelerSignal extends SignalBase< 14 + ScalarTypes['STRING'], 15 + { scalarType: ScalarTypes['NUMBER'] } 16 + > { 17 + constructor( 18 + protected readonly getZentropiCredentials: CachedGetCredentials<'ZENTROPI'>, 19 + protected readonly getZentropiScores: FetchZentropiScores, 20 + ) { 21 + super(); 22 + } 23 + 24 + override get id() { 25 + return { type: SignalType.ZENTROPI_LABELER }; 26 + } 27 + 28 + override get displayName() { 29 + return 'Zentropi Labeler'; 30 + } 31 + 32 + override get description() { 33 + return ( 34 + 'Policy-steerable content classifier powered by Zentropi. ' + 35 + 'Evaluates text against a custom policy defined by a published labeler. ' + 36 + 'Returns a composite score: 0 = confidently safe, 0.5 = uncertain, 1 = confidently violating. ' + 37 + 'Specify the labeler_version_id in the subcategory field.' 38 + ); 39 + } 40 + 41 + override get docsUrl() { 42 + return 'https://docs.zentropi.ai'; 43 + } 44 + 45 + override get integration() { 46 + return Integration.ZENTROPI; 47 + } 48 + 49 + override get pricingStructure() { 50 + return SignalPricingStructure.SUBSCRIPTION; 51 + } 52 + 53 + override get recommendedThresholds() { 54 + return { 55 + highPrecisionThreshold: 0.8, 56 + highRecallThreshold: 0.6, 57 + }; 58 + } 59 + 60 + override get supportedLanguages() { 61 + return 'ALL' as const; 62 + } 63 + 64 + override get eligibleSubcategories() { 65 + return []; 66 + } 67 + 68 + override get needsActionPenalties() { 69 + return false; 70 + } 71 + 72 + override get needsMatchingValues() { 73 + return false; 74 + } 75 + 76 + override async getDisabledInfo(orgId: string) { 77 + const credential = await this.getZentropiCredentials(orgId); 78 + return !credential?.apiKey 79 + ? { 80 + disabled: true as const, 81 + disabledMessage: 82 + 'You need to input your Zentropi API key to use Zentropi signals', 83 + } 84 + : { disabled: false as const }; 85 + } 86 + 87 + override get eligibleInputs() { 88 + return [ScalarTypes.STRING]; 89 + } 90 + 91 + override get outputType() { 92 + return { scalarType: ScalarTypes.NUMBER }; 93 + } 94 + 95 + /** 96 + * Placeholder estimate 97 + */ 98 + override getCost() { 99 + return 20; 100 + } 101 + 102 + override get allowedInAutomatedRules() { 103 + return true; 104 + } 105 + 106 + async run(input: SignalInput<ScalarTypes['STRING']>) { 107 + return runZentropiLabelerImpl( 108 + this.getZentropiCredentials, 109 + input, 110 + this.getZentropiScores, 111 + ); 112 + } 113 + }
+291
server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.test.ts
··· 1 + import { ScalarTypes } from '@roostorg/types'; 2 + 3 + import { isCoopErrorOfType } from '../../../../../utils/errors.js'; 4 + import { type FetchHTTP } from '../../../../networkingService/index.js'; 5 + import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; 6 + import { type SignalInput } from '../../SignalBase.js'; 7 + import { 8 + getZentropiScores, 9 + runZentropiLabelerImpl, 10 + type FetchZentropiScores, 11 + type ZentropiResponse, 12 + } from './zentropiUtils.js'; 13 + 14 + type StringSignalInput = SignalInput<ScalarTypes['STRING']>; 15 + 16 + function makeInput( 17 + overrides: Partial<StringSignalInput> = {}, 18 + ): StringSignalInput { 19 + return { 20 + value: { type: 'STRING', value: 'test content' }, 21 + matchingValues: undefined, 22 + actionPenalties: undefined, 23 + orgId: 'org-1', 24 + subcategory: 'lv_abc123', 25 + ...overrides, 26 + } as unknown as StringSignalInput; 27 + } 28 + 29 + function makeCredentialGetter( 30 + apiKey: string | null = 'test-api-key', 31 + ): CachedGetCredentials<'ZENTROPI'> { 32 + return Object.assign( 33 + jest 34 + .fn() 35 + .mockResolvedValue( 36 + apiKey ? { apiKey } : undefined, 37 + ) as unknown as CachedGetCredentials<'ZENTROPI'>, 38 + { close: jest.fn().mockResolvedValue(undefined) }, 39 + ); 40 + } 41 + 42 + describe('zentropiUtils', () => { 43 + describe('score mapping', () => { 44 + it('maps label=1, high confidence to high score (violating)', async () => { 45 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 46 + label: 1, 47 + confidence: 0.95, 48 + } satisfies ZentropiResponse); 49 + 50 + const result = await runZentropiLabelerImpl( 51 + makeCredentialGetter(), 52 + makeInput(), 53 + fetchScores, 54 + ); 55 + 56 + expect(result.score).toBe(0.95); 57 + }); 58 + 59 + it('maps label=0, high confidence to low score (safe)', async () => { 60 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 61 + label: 0, 62 + confidence: 0.95, 63 + } satisfies ZentropiResponse); 64 + 65 + const result = await runZentropiLabelerImpl( 66 + makeCredentialGetter(), 67 + makeInput(), 68 + fetchScores, 69 + ); 70 + 71 + expect(result.score).toBeCloseTo(0.05); 72 + }); 73 + 74 + it('maps label=0, low confidence to ~0.4 (uncertain, leaning safe)', async () => { 75 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 76 + label: 0, 77 + confidence: 0.6, 78 + } satisfies ZentropiResponse); 79 + 80 + const result = await runZentropiLabelerImpl( 81 + makeCredentialGetter(), 82 + makeInput(), 83 + fetchScores, 84 + ); 85 + 86 + expect(result.score).toBeCloseTo(0.4); 87 + }); 88 + 89 + it('maps label=1, low confidence to 0.6 (uncertain, leaning violating)', async () => { 90 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 91 + label: 1, 92 + confidence: 0.6, 93 + } satisfies ZentropiResponse); 94 + 95 + const result = await runZentropiLabelerImpl( 96 + makeCredentialGetter(), 97 + makeInput(), 98 + fetchScores, 99 + ); 100 + 101 + expect(result.score).toBe(0.6); 102 + }); 103 + 104 + it('handles label as string "1" (API returns strings)', async () => { 105 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 106 + label: '1', 107 + confidence: 0.95, 108 + } satisfies ZentropiResponse); 109 + 110 + const result = await runZentropiLabelerImpl( 111 + makeCredentialGetter(), 112 + makeInput(), 113 + fetchScores, 114 + ); 115 + 116 + expect(result.score).toBe(0.95); 117 + }); 118 + 119 + it('handles label as string "0" (API returns strings)', async () => { 120 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 121 + label: '0', 122 + confidence: 0.95, 123 + } satisfies ZentropiResponse); 124 + 125 + const result = await runZentropiLabelerImpl( 126 + makeCredentialGetter(), 127 + makeInput(), 128 + fetchScores, 129 + ); 130 + 131 + expect(result.score).toBeCloseTo(0.05); 132 + }); 133 + 134 + it('returns correct outputType', async () => { 135 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 136 + label: 1, 137 + confidence: 0.9, 138 + } satisfies ZentropiResponse); 139 + 140 + const result = await runZentropiLabelerImpl( 141 + makeCredentialGetter(), 142 + makeInput(), 143 + fetchScores, 144 + ); 145 + 146 + expect(result.outputType).toEqual({ scalarType: ScalarTypes.NUMBER }); 147 + }); 148 + }); 149 + 150 + describe('error handling', () => { 151 + it('throws when missing credentials', async () => { 152 + const fetchScores: FetchZentropiScores = jest.fn(); 153 + 154 + await expect( 155 + runZentropiLabelerImpl( 156 + makeCredentialGetter(null), 157 + makeInput(), 158 + fetchScores, 159 + ), 160 + ).rejects.toThrow('Missing Zentropi API credentials'); 161 + }); 162 + 163 + it('throws when missing subcategory', async () => { 164 + const fetchScores: FetchZentropiScores = jest.fn(); 165 + 166 + await expect( 167 + runZentropiLabelerImpl( 168 + makeCredentialGetter(), 169 + makeInput({ subcategory: undefined }), 170 + fetchScores, 171 + ), 172 + ).rejects.toThrow('Missing labeler_version_id in subcategory'); 173 + }); 174 + 175 + it('passes labelerVersionId from subcategory to fetcher', async () => { 176 + const fetchScores: FetchZentropiScores = jest.fn().mockResolvedValue({ 177 + label: 0, 178 + confidence: 0.9, 179 + } satisfies ZentropiResponse); 180 + 181 + await runZentropiLabelerImpl( 182 + makeCredentialGetter(), 183 + makeInput({ subcategory: 'lv_custom_123' }), 184 + fetchScores, 185 + ); 186 + 187 + expect(fetchScores).toHaveBeenCalledWith({ 188 + text: 'test content', 189 + apiKey: 'test-api-key', 190 + labelerVersionId: 'lv_custom_123', 191 + }); 192 + }); 193 + }); 194 + 195 + describe('getZentropiScores', () => { 196 + it('returns SignalPermanentError for 404', async () => { 197 + const mockFetchHTTP = jest.fn().mockResolvedValue({ 198 + ok: false, 199 + status: 404, 200 + }) as unknown as FetchHTTP; 201 + 202 + try { 203 + await getZentropiScores(mockFetchHTTP, { 204 + text: 'test', 205 + apiKey: 'key', 206 + labelerVersionId: 'lv_bad', 207 + }); 208 + fail('Expected error to be thrown'); 209 + } catch (e) { 210 + expect(isCoopErrorOfType(e, 'SignalPermanentError')).toBe(true); 211 + } 212 + }); 213 + 214 + it('returns SignalPermanentError for 401', async () => { 215 + const mockFetchHTTP = jest.fn().mockResolvedValue({ 216 + ok: false, 217 + status: 401, 218 + }) as unknown as FetchHTTP; 219 + 220 + try { 221 + await getZentropiScores(mockFetchHTTP, { 222 + text: 'test', 223 + apiKey: 'bad-key', 224 + labelerVersionId: 'lv_123', 225 + }); 226 + fail('Expected error to be thrown'); 227 + } catch (e) { 228 + expect(isCoopErrorOfType(e, 'SignalPermanentError')).toBe(true); 229 + } 230 + }); 231 + 232 + it('throws transient error for 5xx', async () => { 233 + const mockFetchHTTP = jest.fn().mockResolvedValue({ 234 + ok: false, 235 + status: 500, 236 + }) as unknown as FetchHTTP; 237 + 238 + await expect( 239 + getZentropiScores(mockFetchHTTP, { 240 + text: 'test', 241 + apiKey: 'key', 242 + labelerVersionId: 'lv_123', 243 + }), 244 + ).rejects.toThrow('Zentropi API error: 500'); 245 + 246 + // Verify it's NOT a SignalPermanentError 247 + try { 248 + await getZentropiScores(mockFetchHTTP, { 249 + text: 'test', 250 + apiKey: 'key', 251 + labelerVersionId: 'lv_123', 252 + }); 253 + } catch (e) { 254 + expect(isCoopErrorOfType(e, 'SignalPermanentError')).toBe(false); 255 + } 256 + }); 257 + 258 + it('returns parsed response on success', async () => { 259 + const mockResponse: ZentropiResponse = { 260 + label: 1, 261 + confidence: 0.85, 262 + explanation: 'Content violates policy', 263 + }; 264 + 265 + const mockFetchHTTP = jest.fn().mockResolvedValue({ 266 + ok: true, 267 + body: mockResponse, 268 + }) as unknown as FetchHTTP; 269 + 270 + const result = await getZentropiScores(mockFetchHTTP, { 271 + text: 'test content', 272 + apiKey: 'key', 273 + labelerVersionId: 'lv_123', 274 + }); 275 + 276 + expect(result).toEqual(mockResponse); 277 + expect(mockFetchHTTP).toHaveBeenCalledWith( 278 + expect.objectContaining({ 279 + url: 'https://api.zentropi.ai/v1/label', 280 + method: 'post', 281 + headers: { 282 + Authorization: 'Bearer key', 283 + 'Content-Type': 'application/json', 284 + }, 285 + handleResponseBody: 'as-json', 286 + timeoutMs: 5_000, 287 + }), 288 + ); 289 + }); 290 + }); 291 + });
+99
server/services/signalsService/signals/third_party_signals/zentropi/zentropiUtils.ts
··· 1 + import { ScalarTypes } from '@roostorg/types'; 2 + 3 + import { jsonStringify } from '../../../../../utils/encoding.js'; 4 + import { makeSignalPermanentError } from '../../../../../utils/errors.js'; 5 + import { type Bind1 } from '../../../../../utils/typescript-types.js'; 6 + import { type FetchHTTP } from '../../../../networkingService/index.js'; 7 + import { type CachedGetCredentials } from '../../../../signalAuthService/signalAuthService.js'; 8 + import { type SignalInput } from '../../SignalBase.js'; 9 + 10 + export interface ZentropiResponse { 11 + label: 0 | 1 | '0' | '1'; 12 + confidence: number; 13 + explanation?: string; 14 + } 15 + 16 + export type FetchZentropiScores = Bind1< 17 + typeof getZentropiScores, 18 + FetchHTTP 19 + >; 20 + 21 + export async function getZentropiScores( 22 + fetchHTTP: FetchHTTP, 23 + params: { 24 + text: string; 25 + apiKey: string; 26 + labelerVersionId: string; 27 + }, 28 + ): Promise<ZentropiResponse> { 29 + const response = await fetchHTTP({ 30 + url: 'https://api.zentropi.ai/v1/label', 31 + method: 'post', 32 + headers: { 33 + Authorization: `Bearer ${params.apiKey}`, 34 + 'Content-Type': 'application/json', 35 + }, 36 + body: jsonStringify({ 37 + content_text: params.text, 38 + labeler_version_id: params.labelerVersionId, 39 + }), 40 + handleResponseBody: 'as-json', 41 + timeoutMs: 5_000, 42 + }); 43 + 44 + if (!response.ok) { 45 + if (response.status === 404 || response.status === 401) { 46 + throw makeSignalPermanentError( 47 + `Zentropi API error: ${response.status}${ 48 + response.status === 404 49 + ? ' (invalid labeler_version_id)' 50 + : ' (invalid API key)' 51 + }`, 52 + { shouldErrorSpan: true }, 53 + ); 54 + } 55 + throw new Error(`Zentropi API error: ${response.status}`); 56 + } 57 + 58 + return response.body as unknown as ZentropiResponse; 59 + } 60 + 61 + export async function runZentropiLabelerImpl( 62 + getZentropiCredentials: CachedGetCredentials<'ZENTROPI'>, 63 + input: SignalInput<ScalarTypes['STRING']>, 64 + fetchScores: FetchZentropiScores, 65 + ) { 66 + const { value, orgId, subcategory } = input; 67 + 68 + const credential = await getZentropiCredentials(orgId); 69 + // eslint-disable-next-line security/detect-possible-timing-attacks 70 + if (!credential?.apiKey) { 71 + throw new Error('Missing Zentropi API credentials'); 72 + } 73 + 74 + if (!subcategory) { 75 + throw new Error( 76 + 'Missing labeler_version_id in subcategory. ' + 77 + 'Specify a Zentropi labeler_version_id in the condition subcategory field.', 78 + ); 79 + } 80 + 81 + const response = await fetchScores({ 82 + text: value.value, 83 + apiKey: credential.apiKey, 84 + labelerVersionId: subcategory, 85 + }); 86 + 87 + // Composite score mapping: 88 + // label=1 (violating) → pass confidence through 89 + // label=0 (safe) → invert confidence 90 + // Result: 0 = confidently safe, 0.5 = uncertain, 1 = confidently violating 91 + const { label, confidence } = response; 92 + const score = Number(label) === 1 ? confidence : 1 - confidence; 93 + 94 + return { 95 + score, 96 + outputType: { scalarType: ScalarTypes.NUMBER }, 97 + }; 98 + } 99 +
+1
server/services/signalsService/types/Integration.ts
··· 7 7 export const Integration = makeEnumLike([ 8 8 'GOOGLE_CONTENT_SAFETY_API', 9 9 'OPEN_AI', 10 + 'ZENTROPI', 10 11 ]); 11 12 12 13 export type Integration = keyof typeof Integration;
+2
server/services/signalsService/types/SignalArgsByType.ts
··· 32 32 [SignalType.OPEN_AI_SEXUAL_MINORS_TEXT_MODEL]: undefined; 33 33 [SignalType.OPEN_AI_SEXUAL_TEXT_MODEL]: undefined; 34 34 [SignalType.OPEN_AI_VIOLENCE_TEXT_MODEL]: undefined; 35 + [SignalType.ZENTROPI_LABELER]: undefined; 35 36 [SignalType.CUSTOM]: undefined; 36 37 }, 37 38 { [K in SignalType]: unknown } ··· 66 67 [SignalType.OPEN_AI_SEXUAL_MINORS_TEXT_MODEL]: undefined; 67 68 [SignalType.OPEN_AI_SEXUAL_TEXT_MODEL]: undefined; 68 69 [SignalType.OPEN_AI_VIOLENCE_TEXT_MODEL]: undefined; 70 + [SignalType.ZENTROPI_LABELER]: undefined; 69 71 [SignalType.CUSTOM]: undefined; 70 72 }, 71 73 { [K in SignalType]: unknown }
+3
server/services/signalsService/types/SignalType.ts
··· 48 48 'OPEN_AI_SEXUAL_MINORS_TEXT_MODEL', 49 49 'OPEN_AI_SEXUAL_TEXT_MODEL', 50 50 'OPEN_AI_VIOLENCE_TEXT_MODEL', 51 + 'ZENTROPI_LABELER', 51 52 ]); 52 53 53 54 export const UserCreatedExternalSignalType = makeEnumLike(['CUSTOM']); ··· 98 99 case 'OPEN_AI_VIOLENCE_TEXT_MODEL': 99 100 case 'OPEN_AI_WHISPER_TRANSCRIPTION': 100 101 return Integration.OPEN_AI; 102 + case 'ZENTROPI_LABELER': 103 + return Integration.ZENTROPI; 101 104 case 'AGGREGATION': 102 105 case 'CUSTOM': 103 106 case 'GEO_CONTAINED_WITHIN':