[CU-8689pphvt] Adds content ingestion for models (#1397)

* Adds service and webhook to ingest model content

* Adds migration and adjustments to allow model ingestion

* Adds reports page for mods

* Adds example env vars

* Fixes after merging with main

* 5.0.139

* Adjusts moderator/models page

* Fix images API endpoint not supporting postId properly

* 5.0.143

* Adds a quick way for mods to update a flagged model while resolving

* Prevents awaiting model ingestion

* Allows sorting flagged models

---------

Co-authored-by: Luis Rojas <lrojas94@gmail.com>
This commit is contained in:
Manuel Emilio Urena
2024-10-04 15:39:40 -04:00
committed by GitHub
parent 783f46efd0
commit 4a4f50f09e
19 changed files with 973 additions and 230 deletions
+4
View File
@@ -177,3 +177,7 @@ CF_INVISIBLE_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
NEXT_PUBLIC_CF_MANAGED_TURNSTILE_SITEKEY=1x00000000000000000000AA
CF_MANAGED_TURNSTILE_SECRET=1x0000000000000000000000000000000AA
# Model Ingestion
CONTENT_SCAN_ENDPOINT=http://localhost:3000
CONTENT_SCAN_CALLBACK_URL=http://localhost:3000
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "ModelFlagStatus" AS ENUM ('Pending', 'Resolved');
-- AlterTable
ALTER TABLE "Model" ADD COLUMN "scannedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "ModelFlag" ADD COLUMN "minor" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "nsfw" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "poi" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "status" "ModelFlagStatus" NOT NULL DEFAULT 'Pending',
ADD COLUMN "triggerWords" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "ModelFlag" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "details" JSONB;
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "ModelFlag" DROP COLUMN "nameNsfw",
ADD COLUMN "poiName" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "ModelFlag_status_idx" ON "ModelFlag"("status");
+18 -3
View File
@@ -544,6 +544,7 @@ model Model {
availability Availability @default(Public)
nsfwLevel Int @default(0)
lockedProperties String[] @default([])
scannedAt DateTime?
// Licensing
allowNoCredit Boolean @default(true)
@@ -577,10 +578,24 @@ model Model {
@@index([status, nsfw])
}
enum ModelFlagStatus {
Pending
Resolved
}
model ModelFlag {
modelId Int @id
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
nameNsfw Boolean @default(false)
modelId Int @id
model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)
poi Boolean @default(false)
minor Boolean @default(false)
nsfw Boolean @default(false)
triggerWords Boolean @default(false)
poiName Boolean @default(false)
status ModelFlagStatus @default(Pending)
details Json?
createdAt DateTime @default(now())
@@index([status])
}
model License {
@@ -0,0 +1,282 @@
import { Anchor, Badge, Button, Chip, JsonInput, Modal, Paper, Stack, Text } from '@mantine/core';
import { IconExternalLink } from '@tabler/icons-react';
import {
MantineReactTable,
MRT_ColumnDef,
MRT_PaginationState,
MRT_SortingState,
} from 'mantine-react-table';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { z } from 'zod';
import { Collection } from '~/components/Collection/Collection';
import { useDialogContext } from '~/components/Dialog/DialogProvider';
import { dialogStore } from '~/components/Dialog/dialogStore';
import { Form, InputCheckbox, InputRTE, InputText, useForm } from '~/libs/form';
import { modelUpsertSchema } from '~/server/schema/model.schema';
import { showErrorNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { isNumber } from '~/utils/type-guards';
export function FlaggedModelsList() {
const router = useRouter();
const page = isNumber(router.query.page) ? Number(router.query.page) : 1;
const [pagination, setPagination] = useState<MRT_PaginationState>({
pageIndex: page - 1,
pageSize: 20,
});
const [sorting, setSorting] = useState<MRT_SortingState>([{ id: 'createdAt', desc: true }]);
const { data, isLoading, isFetching, isRefetching } = trpc.moderator.models.queryFlagged.useQuery(
{ page: pagination.pageIndex + 1, limit: pagination.pageSize, sort: sorting }
);
const flaggedModels = data?.items ?? [];
const columns = useMemo<MRT_ColumnDef<(typeof flaggedModels)[number]>[]>(
() => [
{
id: 'modelId',
header: 'Model',
accessorKey: 'model.name',
enableColumnActions: false,
enableSorting: false,
Cell: ({ row: { original } }) => (
<Link href={`/models/${original.modelId}?view=basic`} passHref>
<Anchor target="_blank">
<div className="flex flex-nowrap gap-1">
<IconExternalLink className="shrink-0 grow-0" size={16} />
<Text span inline>
{original.model.name}
</Text>
</div>
</Anchor>
</Link>
),
},
{
header: 'Review',
accessorFn: (row) => row.model.id,
enableColumnActions: false,
enableSorting: false,
Cell: ({ row: { original } }) => {
const items = Object.entries(original)
.filter(
([key, value]) =>
['poi', 'nsfw', 'minor', 'triggerWords', 'poiName'].includes(key) && !!value
)
.map(([key, value]) => ({ name: key, value }));
return (
<Collection
items={items}
renderItem={(item) => <Badge color="yellow">{item.name}</Badge>}
grouped
/>
);
},
},
{
header: 'Action',
accessorKey: 'model.id',
size: 100,
enableColumnActions: false,
enableSorting: false,
mantineTableHeadCellProps: { align: 'right' },
mantineTableBodyCellProps: { align: 'right' },
Cell: ({ row: { original } }) => (
<Button
size="sm"
onClick={() =>
dialogStore.trigger({
component: DetailsModal,
props: { model: original.model, details: original.details },
})
}
compact
>
Resolve
</Button>
),
},
],
[]
);
return (
<MantineReactTable
columns={columns}
data={flaggedModels}
rowCount={data?.totalItems ?? 0}
maxMultiSortColCount={2}
onPaginationChange={setPagination}
onSortingChange={setSorting}
enableStickyHeader
enableSortingRemoval
enableHiding={false}
enableGlobalFilter={false}
enableColumnFilters={false}
mantineTableProps={{
sx: { tableLayout: 'fixed' },
}}
mantineTableContainerProps={{ sx: { maxHeight: 450 } }}
initialState={{
density: 'xs',
}}
state={{
isLoading: isLoading || isRefetching,
showProgressBars: isFetching,
sorting,
pagination,
}}
renderTopToolbarCustomActions={({ table }) => (
<div className="flex items-center gap-2">
<Text span>Filters: </Text>
<Chip
size="xs"
variant="filled"
onChange={(value) =>
value
? setSorting([
{ id: 'poi', desc: true },
{ id: 'nsfw', desc: true },
])
: table.resetSorting(true)
}
>
High Priority
</Chip>
</div>
)}
/>
);
}
const schema = modelUpsertSchema.pick({
id: true,
name: true,
description: true,
poi: true,
nsfw: true,
minor: true,
type: true,
uploadType: true,
status: true,
});
function DetailsModal({ model, details }: { model: z.infer<typeof schema>; details: MixedObject }) {
const context = useDialogContext();
const queryUtils = trpc.useUtils();
const form = useForm({ schema, defaultValues: { ...model }, shouldUnregister: false });
const isDirty = form.formState.isDirty;
const upsertModelMutation = trpc.model.upsert.useMutation({
onSuccess: async (result) => {
await queryUtils.model.getById.invalidate({ id: result.id });
},
onError: (error) => {
showErrorNotification({
title: 'Error saving model',
error: new Error(error.message),
});
},
});
const resolveFlaggedModelMutation = trpc.moderator.models.resolveFlagged.useMutation({
onSuccess: async () => {
await queryUtils.moderator.models.queryFlagged.invalidate();
},
onError: (error) => {
showErrorNotification({
title: 'Error resolving flagged model',
error: new Error(error.message),
});
},
});
const handleSubmit = async (data: z.infer<typeof schema>) => {
if (!data.id) return;
try {
if (isDirty) await upsertModelMutation.mutateAsync(data);
await resolveFlaggedModelMutation.mutateAsync({ id: data.id });
context.onClose();
} catch {
// Error is handled in the mutation
}
};
const [poi, nsfw] = form.watch(['poi', 'nsfw']);
return (
<Modal {...context} title="Resolve Model" size="75%" centered>
<div className="flex flex-nowrap gap-8">
<Form form={form} onSubmit={handleSubmit}>
<div className="flex flex-col gap-4">
<InputText name="name" label="Name" placeholder="Name" withAsterisk />
<InputRTE
name="description"
label="Description"
description="Tell us what your model does"
includeControls={[
'heading',
'formatting',
'list',
'link',
'media',
'mentions',
'colors',
]}
editorSize="xl"
placeholder="What does your model do? What's it for? What is your model good at? What should it be used for? What is your resource bad at? How should it not be used?"
withAsterisk
/>
<Paper radius="md" p="xl" withBorder>
<Stack spacing="xs">
<Text size="md" weight={500}>
This resource:
</Text>
<InputCheckbox
name="poi"
label="Depicts an actual person (Resource cannot be used on Civitai on-site Generator)"
onChange={(e) => {
form.setValue('nsfw', e.target.checked ? false : undefined);
}}
/>
<InputCheckbox
name="nsfw"
label="Is intended to produce mature themes"
disabled={poi}
onChange={(event) =>
event.target.checked ? form.setValue('minor', false) : null
}
/>
<InputCheckbox
name="minor"
label="Cannot be used for NSFW generation"
disabled={nsfw}
/>
</Stack>
</Paper>
<Button
type="submit"
loading={resolveFlaggedModelMutation.isLoading || upsertModelMutation.isLoading}
>
Save & Resolve
</Button>
</div>
</Form>
<div className="w-64 flex-none">
<Text size="md" weight={600} className="mb-2">
Scan Details
</Text>
<JsonInput value={JSON.stringify(details, null, 2)} minRows={4} formatOnBlur autosize />
</div>
</div>
</Modal>
);
}
+3
View File
@@ -181,6 +181,9 @@ export const serverSchema = z.object({
CLOUDFLARE_TURNSTILE_SECRET: z.string().optional(),
CF_INVISIBLE_TURNSTILE_SECRET: z.string().optional(),
CF_MANAGED_TURNSTILE_SECRET: z.string().optional(),
CONTENT_SCAN_ENDPOINT: z.string().optional(),
CONTENT_SCAN_CALLBACK_URL: z.string().optional(),
CONTENT_SCAN_MODEL: z.string().optional(),
});
/**
+59 -57
View File
@@ -3,12 +3,12 @@ import { z } from 'zod';
import { dbRead } from '~/server/db/client';
import { dataProcessor } from '~/server/db/db-helpers';
import { pgDbWrite } from '~/server/db/pgDb';
import { ingestModel } from '~/server/services/model.service';
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
import { hasNsfwWords } from '~/utils/metadata/audit';
const schema = z.object({
concurrency: z.coerce.number().min(1).max(50).optional().default(15),
batchSize: z.coerce.number().min(0).optional().default(500),
concurrency: z.coerce.number().min(1).max(10).optional().default(1),
batchSize: z.coerce.number().min(0).optional().default(100),
start: z.coerce.number().min(0).optional().default(0),
end: z.coerce.number().min(0).optional(),
after: z.coerce.date().optional(),
@@ -16,60 +16,62 @@ const schema = z.object({
});
export default WebhookEndpoint(async (req, res) => {
const params = schema.parse(req.query);
let totalProcessed = 0;
let totalTitleNsfw = 0;
try {
const params = schema.parse(req.query);
let totalProcessed = 0;
await dataProcessor({
params,
runContext: res,
rangeFetcher: async (context) => {
if (params.after) {
const results = await dbRead.$queryRaw<{ start: number; end: number }[]>`
WITH dates AS (
SELECT
MIN("createdAt") as start,
MAX("createdAt") as end
FROM "Model" WHERE "createdAt" > ${params.after}
)
SELECT MIN(id) as start, MAX(id) as end
FROM "Model" i
JOIN dates d ON d.start = i."createdAt" OR d.end = i."createdAt";`;
return results[0];
}
const [{ max }] = await dbRead.$queryRaw<{ max: number }[]>(
Prisma.sql`SELECT MAX(id) "max" FROM "Model";`
);
return { ...context, end: max };
},
processor: async ({ start, end, cancelFns }) => {
const modelsQuery = await pgDbWrite.cancellableQuery<{ id: number; name: string }>(Prisma.sql`
SELECT id, name FROM "Model" WHERE id BETWEEN ${start} AND ${end}
`);
cancelFns.push(modelsQuery.cancel);
const models = await modelsQuery.result();
const toInsert = models
.map(({ id, name }) => {
return { id, titleNsfw: hasNsfwWords(name) };
})
.filter((x) => x.titleNsfw);
totalProcessed += models.length;
totalTitleNsfw += toInsert.length;
if (toInsert.length > 0) {
const insertQuery = await pgDbWrite.cancellableQuery(Prisma.sql`
INSERT INTO "ModelFlag" ("modelId", "titleNsfw")
VALUES ${Prisma.raw(
toInsert.map(({ id, titleNsfw }) => `(${id}, ${titleNsfw})`).join(', ')
)}
ON CONFLICT DO NOTHING;
await dataProcessor({
params,
runContext: res,
rangeFetcher: async (context) => {
if (params.after) {
const results = await dbRead.$queryRaw<{ start: number; end: number }[]>`
WITH dates AS (
SELECT
MIN("createdAt") as start,
MAX("createdAt") as end
FROM "Model" WHERE "createdAt" > ${params.after}
)
SELECT MIN(id) as start, MAX(id) as end
FROM "Model" i
JOIN dates d ON d.start = i."createdAt" OR d.end = i."createdAt";`;
return results[0];
}
const [{ max }] = await dbRead.$queryRaw<{ max: number }[]>(
Prisma.sql`SELECT MAX(id) "max" FROM "Model";`
);
return { ...context, end: max };
},
processor: async ({ start, end, cancelFns }) => {
const modelsQuery = await pgDbWrite.cancellableQuery<{
id: number;
name: string;
description: string;
poi: boolean;
nsfw: boolean;
minor: boolean;
}>(Prisma.sql`
SELECT id, name, description, poi, nsfw, minor
FROM "Model"
WHERE id BETWEEN ${start} AND ${end}
AND (status = 'Published'::"ModelStatus" OR status = 'Scheduled'::"ModelStatus")
`);
cancelFns.push(insertQuery.cancel);
await insertQuery.result();
}
console.log(`Processed models ${start} - ${end}`, { totalProcessed, totalTitleNsfw });
},
});
cancelFns.push(modelsQuery.cancel);
const models = await modelsQuery.result();
if (!models.length) return;
const toIngest = models.map(ingestModel);
await Promise.all(toIngest);
totalProcessed += toIngest.length;
console.log(`Processed models ${start} - ${end}`, { totalProcessed });
},
});
return res.status(200).json({ fiished: true, totalProcessed });
} catch (error) {
const e = error as Error;
return res.status(500).json({ error: e.message, query: req.query });
}
});
@@ -0,0 +1,64 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { dbWrite } from '~/server/db/client';
import { logToAxiom } from '~/server/logging/client';
import { modelScanResultSchema } from '~/server/schema/model-flag.schema';
import { upsertModelFlag } from '~/server/services/model-flag.service';
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
const logWebhook = (data: MixedObject) => {
logToAxiom({ name: 'model-scan-result', type: 'error', ...data }, 'webhooks').catch(() => null);
};
export default WebhookEndpoint(async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
logWebhook({ message: 'Wrong method', data: { method: req.method, input: req.body } });
return res.status(405).json({ error: 'Method Not Allowed' });
}
const result = modelScanResultSchema.safeParse(req.body);
if (!result.success) {
logWebhook({
message: 'Could not parse body',
data: { error: result.error.format(), input: req.body },
});
return res.status(400).json({ error: 'Invalid Request', details: result.error.format() });
}
const data = result.data;
if (data.status === 'failure') {
logWebhook({
message: 'Model scan failed',
data: { input: req.body },
});
return res.status(500).json({ error: 'Could not scan model' });
}
try {
// Check scan results and handle accordingly
await dbWrite.model.update({
where: { id: data.user_declared.content.id },
data: { scannedAt: new Date() },
});
await upsertModelFlag({
modelId: data.user_declared.content.id,
scanResult: {
poi: data.flags.POI_flag,
nsfw: data.flags.NSFW_flag,
minor: data.flags.minor_flag,
triggerWords: data.flags.triggerwords_flag,
poiName: !!data.llm_interrogation.POIName?.length,
},
details: data.llm_interrogation,
});
return res.status(200).json({ ok: true });
} catch (error) {
logWebhook({
message: 'Unhandled exception',
data: { error, input: req.body },
});
return res.status(500).json({ error: 'Internal Server Error', details: error });
}
});
+13 -5
View File
@@ -100,7 +100,7 @@ import { useCurrentUser } from '~/hooks/useCurrentUser';
import useIsClient from '~/hooks/useIsClient';
import { openContext } from '~/providers/CustomModalsProvider';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { BaseModel, CAROUSEL_LIMIT } from '~/server/common/constants';
import { CAROUSEL_LIMIT } from '~/server/common/constants';
import { ImageSort, ModelType } from '~/server/common/enums';
import { unpublishReasons } from '~/server/common/moderation-helpers';
import { ModelMeta } from '~/server/schema/model.schema';
@@ -113,7 +113,13 @@ import { formatDate, isFutureDate } from '~/utils/date-helpers';
import { containerQuery } from '~/utils/mantine-css-helpers';
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
import { abbreviateNumber } from '~/utils/number-helpers';
import { getBaseModelEcosystemName, getDisplayName, removeTags, slugit, splitUppercase } from '~/utils/string-helpers';
import {
getBaseModelEcosystemName,
getDisplayName,
removeTags,
slugit,
splitUppercase,
} from '~/utils/string-helpers';
import { trpc } from '~/utils/trpc';
import { isNumber } from '~/utils/type-guards';
@@ -203,6 +209,7 @@ export default function ModelDetailsV2({
}
);
const view = router.query.view;
const rawVersionId = router.query.modelVersionId;
const modelVersionId = Number(
(Array.isArray(rawVersionId) ? rawVersionId[0] : rawVersionId) ?? model?.modelVersions[0]?.id
@@ -227,7 +234,7 @@ export default function ModelDetailsV2({
publishedVersions[0] ??
null;
const [selectedVersion, setSelectedVersion] = useState<ModelVersionDetail | null>(latestVersion);
const selectedEcosystemName = getBaseModelEcosystemName(selectedVersion?.baseModel)
const selectedEcosystemName = getBaseModelEcosystemName(selectedVersion?.baseModel);
const tippedAmount = useBuzzTippingStore({ entityType: 'Model', entityId: model?.id ?? -1 });
const { canDownload: hasDownloadPermissions, canGenerate: hasGeneratePermissions } =
@@ -508,12 +515,13 @@ export default function ModelDetailsV2({
isFutureDate(selectedVersion.earlyAccessDeadline);
const category = model.tagsOnModels.find(({ tag }) => !!tag.isCategory)?.tag;
const tags = model.tagsOnModels.filter(({ tag }) => !tag.isCategory).map((tag) => tag.tag);
const canLoadBelowTheFold = isClient && !loadingModel && !loadingImages;
const basicView = view === 'basic' && isModerator;
const canLoadBelowTheFold = isClient && !loadingModel && !loadingImages && !basicView;
const unpublishedReason = model.meta?.unpublishedReason ?? 'other';
const unpublishedMessage =
unpublishedReason !== 'other'
? unpublishReasons[unpublishedReason]?.notificationMessage
: `Removal reason: ${model.meta?.customMessage}.` ?? '';
: `Removal reason: ${model.meta?.customMessage}.`;
return (
<>
+189 -140
View File
@@ -10,6 +10,7 @@ import {
Modal,
Pagination,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
@@ -21,6 +22,8 @@ import { TRPCClientErrorBase } from '@trpc/client';
import { DefaultErrorShape } from '@trpc/server';
import Link from 'next/link';
import { useState } from 'react';
import { Meta } from '~/components/Meta/Meta';
import { FlaggedModelsList } from '~/components/Moderation/FlaggedModelsList';
import { unpublishReasons } from '~/server/common/moderation-helpers';
import { allBrowsingLevelsFlag } from '~/shared/constants/browsingLevel.constants';
@@ -35,24 +38,31 @@ type State = {
page: number;
opened: boolean;
selectedModel: ModelGetAllPagedSimple['items'][number] | null;
section: 'unpublished' | 'ai';
};
export default function ModeratorModels() {
const queryUtils = trpc.useContext();
const queryUtils = trpc.useUtils();
const [state, setState] = useState<State>({
declineReason: 'Insufficient changes',
page: 1,
opened: false,
selectedModel: null,
section: 'unpublished',
});
const { data, isLoading } = trpc.model.getAllPagedSimple.useQuery({
needsReview: true,
status: [ModelStatus.UnpublishedViolation, ModelStatus.Published],
page: state.page,
limit: 20,
browsingLevel: allBrowsingLevelsFlag,
});
const viewingUnpublished = state.section === 'unpublished';
const { data, isLoading } = trpc.model.getAllPagedSimple.useQuery(
{
needsReview: true,
status: [ModelStatus.UnpublishedViolation, ModelStatus.Published],
page: state.page,
limit: 20,
browsingLevel: allBrowsingLevelsFlag,
},
{ enabled: viewingUnpublished }
);
const { items, ...pagination } = data || {
items: [],
@@ -94,138 +104,177 @@ export default function ModeratorModels() {
setState((s) => ({ ...s, ...partialState, opened: !s.opened }));
return (
<Container size="sm">
<Stack spacing={0} mb="xl">
<Title order={1}>Models Needing Review</Title>
<Text size="sm" color="dimmed">
Unpublished models for violating ToS which their owners have requested a review
</Text>
</Stack>
{isLoading ? (
<Center p="xl">
<Loader size="lg" />
</Center>
) : !!data?.items.length ? (
<Stack>
<List listStyleType="none" spacing="md">
{data?.items.map((model) => {
const hasVersion = !!model.modelVersion;
const unpublishedAt =
hasVersion && model.modelVersion?.meta && model.modelVersion?.meta?.unpublishedAt
? new Date(model.modelVersion.meta.unpublishedAt)
: model.meta && model.meta.unpublishedAt
? new Date(model.meta.unpublishedAt)
: null;
const unpublishedReason =
model.meta?.unpublishedReason ?? model.modelVersion?.meta?.unpublishedReason;
const customMessage =
model.meta?.customMessage ?? model.modelVersion?.meta?.customMessage;
return (
<List.Item
key={model.id}
sx={(theme) => ({
padding: theme.spacing.sm,
border: `1px solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
}`,
'& > *': { width: '100%' },
})}
>
<Group position="apart" align="flex-start" noWrap>
<Stack spacing={0}>
<Group spacing={8} noWrap>
{hasVersion ? (
<Badge color="violet" radius="xl">
Model Version
</Badge>
) : (
<Badge radius="xl">Model</Badge>
)}
<Link
href={`/models/${model.id}/${slugit(model.name)}${
model.modelVersion ? `?modelVersionId=${model.modelVersion.id}` : ''
}`}
passHref
>
<Anchor size="md" target="_blank" lineClamp={2}>
{`${model.name}${
model.modelVersion ? ` - ${model.modelVersion.name}` : ''
}`}{' '}
<IconExternalLink size={16} stroke={1.5} />
</Anchor>
</Link>
</Group>
{unpublishedAt && (
<Text size="xs" color="dimmed">
Unpublished at: {formatDate(unpublishedAt)}
</Text>
)}
{unpublishedReason && (
<Text size="sm">
<Text weight={500} size="sm" span>
Reason initially unpublished:
</Text>{' '}
{`${unpublishReasons[unpublishedReason].optionLabel}${
customMessage ? ` - ${customMessage}` : ''
}`}
</Text>
)}
</Stack>
<Button
variant="subtle"
size="xs"
color="red"
onClick={() => toggleModal({ selectedModel: model })}
compact
>
Decline Request
</Button>
</Group>
</List.Item>
);
})}
</List>
{pagination.totalPages > 1 && (
<Group position="apart">
<Text>Total {pagination.totalItems} items</Text>
<Pagination
page={state.page}
onChange={(page) => setState((s) => ({ ...s, page }))}
total={pagination.totalPages}
/>
</Group>
)}
<Modal opened={state.opened} onClose={() => toggleModal()} title="Decline Request">
<Stack>
<Textarea
name="declineReason"
description="Reason for declining request"
minRows={2}
placeholder="i.e.: Insufficient changes"
value={state.declineReason}
onChange={(e) => setState((s) => ({ ...s, declineReason: e.target.value }))}
/>
<Group position="right">
<Button variant="default" onClick={() => toggleModal()}>
Cancel
</Button>
<Button onClick={handleDeclineRequest} loading={declineReviewMutation.isLoading}>
Send
</Button>
</Group>
</Stack>
</Modal>
<>
<Meta title="Moderator Models" deIndex />
<Container size="sm">
<Stack mb="xl">
<Title order={1}>Models Needing Review</Title>
<SegmentedControl
size="sm"
data={[
{ label: 'Unpublished', value: 'unpublished' },
{ label: 'AI Scanned', value: 'ai' },
]}
onChange={(value) =>
setState((s) => ({
...s,
section: value as State['section'],
page: 1,
opened: false,
selectedModel: null,
}))
}
value={state.section}
/>
<Text size="sm" color="dimmed">
Unpublished models for violating ToS which their owners have requested a review
</Text>
</Stack>
) : (
<Paper p="xl" withBorder>
<Center>
<Text size="md" color="dimmed">
There are no models that need review
</Text>
</Center>
</Paper>
)}
</Container>
{viewingUnpublished ? (
<div>
{isLoading ? (
<Center p="xl">
<Loader size="lg" />
</Center>
) : !!data?.items.length ? (
<Stack>
<List listStyleType="none" spacing="md">
{data?.items.map((model) => {
const hasVersion = !!model.modelVersion;
const unpublishedAt =
hasVersion &&
model.modelVersion?.meta &&
model.modelVersion?.meta?.unpublishedAt
? new Date(model.modelVersion.meta.unpublishedAt)
: model.meta && model.meta.unpublishedAt
? new Date(model.meta.unpublishedAt)
: null;
const unpublishedReason =
model.meta?.unpublishedReason ?? model.modelVersion?.meta?.unpublishedReason;
const customMessage =
model.meta?.customMessage ?? model.modelVersion?.meta?.customMessage;
return (
<List.Item
key={model.id}
sx={(theme) => ({
padding: theme.spacing.sm,
border: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[2]
}`,
'& > *': { width: '100%' },
})}
>
<Stack spacing={8}>
<Group position="apart" align="flex-start" noWrap>
{hasVersion ? (
<Badge color="violet" radius="xl">
Model Version
</Badge>
) : (
<Badge radius="xl">Model</Badge>
)}
<Button
variant="subtle"
size="xs"
color="red"
onClick={() => toggleModal({ selectedModel: model })}
compact
>
Decline Request
</Button>
</Group>
<Link
href={`/models/${model.id}/${slugit(model.name)}${
model.modelVersion ? `?modelVersionId=${model.modelVersion.id}` : ''
}`}
passHref
>
<Anchor size="md" target="_blank" lineClamp={1} inline>
<div className="flex flex-nowrap gap-1">
<IconExternalLink
className="shrink-0 grow-0"
size={16}
stroke={1.5}
/>
{`${model.name}${
model.modelVersion ? ` - ${model.modelVersion.name}` : ''
}`}
</div>
</Anchor>
</Link>
{unpublishedAt && (
<Text size="xs" color="dimmed">
Unpublished at: {formatDate(unpublishedAt)}
</Text>
)}
{unpublishedReason && (
<Text size="sm">
<Text weight={500} size="sm" span>
Reason initially unpublished:
</Text>{' '}
{`${unpublishReasons[unpublishedReason].optionLabel}${
customMessage ? ` - ${customMessage}` : ''
}`}
</Text>
)}
</Stack>
</List.Item>
);
})}
</List>
{pagination.totalPages > 1 && (
<Group position="apart">
<Text>Total {pagination.totalItems} items</Text>
<Pagination
page={state.page}
onChange={(page) => setState((s) => ({ ...s, page }))}
total={pagination.totalPages}
/>
</Group>
)}
<Modal opened={state.opened} onClose={() => toggleModal()} title="Decline Request">
<Stack>
<Textarea
name="declineReason"
description="Reason for declining request"
minRows={2}
placeholder="i.e.: Insufficient changes"
value={state.declineReason}
onChange={(e) => setState((s) => ({ ...s, declineReason: e.target.value }))}
/>
<Group position="right">
<Button variant="default" onClick={() => toggleModal()}>
Cancel
</Button>
<Button
onClick={handleDeclineRequest}
loading={declineReviewMutation.isLoading}
>
Send
</Button>
</Group>
</Stack>
</Modal>
</Stack>
) : (
<Paper p="xl" withBorder>
<Center>
<Text size="md" color="dimmed">
There are no models that need review
</Text>
</Center>
</Paper>
)}
</div>
) : (
<div className="lg:-mx-32">
<FlaggedModelsList />
</div>
)}
</Container>
</>
);
}
+9 -3
View File
@@ -4,16 +4,22 @@ import {
handleApproveTrainingData,
handleDenyTrainingData,
} from '~/server/controllers/training.controller';
import { getByIdSchema, getByIdsSchema } from '~/server/schema/base.schema';
import { getByIdSchema } from '~/server/schema/base.schema';
import { getFlaggedModelsSchema } from '~/server/schema/model-flag.schema';
import { queryModelVersionsSchema } from '~/server/schema/model-version.schema';
import { getAllModelsSchema } from '~/server/schema/model.schema';
import { getVersionById } from '~/server/services/model-version.service';
import { createTrainingRequest } from '~/server/services/training.service';
import { getFlaggedModels, resolveFlaggedModel } from '~/server/services/model-flag.service';
import { moderatorProcedure, router } from '~/server/trpc';
export const modRouter = router({
models: router({
query: moderatorProcedure.input(getAllModelsSchema).query(getModelsPagedSimpleHandler),
queryFlagged: moderatorProcedure
.input(getFlaggedModelsSchema)
.query(({ input }) => getFlaggedModels(input)),
resolveFlagged: moderatorProcedure
.input(getByIdSchema)
.mutation(({ input, ctx }) => resolveFlaggedModel({ ...input, userId: ctx.user.id })),
}),
modelVersions: router({
query: moderatorProcedure
+57
View File
@@ -0,0 +1,57 @@
import { z } from 'zod';
import { getAllQuerySchema } from '~/server/schema/base.schema';
export type GetFlaggedModelsInput = z.infer<typeof getFlaggedModelsSchema>;
export const getFlaggedModelsSchema = getAllQuerySchema.extend({
filters: z
.object({
id: z.string(),
value: z.unknown(),
})
.array()
.optional(),
sort: z
.object({
id: z.string(),
desc: z.boolean(),
})
.array()
.optional(),
});
export type ModelScanResult = z.infer<typeof modelScanResultSchema>;
export const modelScanResultSchema = z.object({
status: z.enum(['success', 'failure']),
user_declared: z.object({
content: z.object({
id: z.number(),
name: z.string(),
POI: z.boolean(),
NSFW: z.boolean(),
minor: z.boolean(),
triggerwords: z.string().array().nullish(),
image_urls: z.string().array().nullish(),
links: z.string().array().nullish(),
}),
}),
llm_interrogation: z.object({
POI: z.boolean(),
POIName: z.string().array().nullish(),
context: z.string().nullish(),
NSFW: z.boolean(),
minor: z.boolean(),
triggerwords: z.string().array(),
POIInfo: z
.object({
POIVerified: z.boolean(),
reason: z.string(),
})
.nullish(),
}),
flags: z.object({
POI_flag: z.boolean(),
NSFW_flag: z.boolean(),
minor_flag: z.boolean(),
triggerwords_flag: z.boolean(),
}),
});
+11
View File
@@ -19,6 +19,7 @@ import { ModelSort } from '~/server/common/enums';
import { UnpublishReason, unpublishReasons } from '~/server/common/moderation-helpers';
import {
baseQuerySchema,
getAllQuerySchema,
getByIdSchema,
infiniteQuerySchema,
paginationSchema,
@@ -324,3 +325,13 @@ export const toggleCheckpointCoverageSchema = z.object({
id: z.number(),
versionId: z.number().nullish(),
});
export type IngestModelInput = z.input<typeof ingestModelSchema>;
export const ingestModelSchema = z.object({
id: z.number(),
name: z.string(),
description: z.coerce.string(),
poi: z.coerce.boolean(),
nsfw: z.coerce.boolean(),
minor: z.coerce.boolean(),
});
@@ -18,7 +18,6 @@ import { getCosmeticsForEntity } from '~/server/services/cosmetic.service';
import { imagesForModelVersionsCache } from '~/server/redis/caches';
import { ImagesForModelVersions } from '~/server/services/image.service';
import { limitConcurrency, Task } from '~/server/utils/concurrency-helpers';
import { removeEmpty } from '~/utils/object-helpers';
const RATING_BAYESIAN_M = 3.5;
const RATING_BAYESIAN_C = 10;
@@ -102,7 +101,6 @@ const onIndexSetup = async ({ indexName }: { indexName: string }) => {
'lastVersionAtUnix',
'versions.hashes',
'versions.baseModel',
'flags.nameNsfw',
];
if (
@@ -154,9 +152,6 @@ const modelSelect = {
mode: true,
checkpointType: true,
availability: true,
flags: {
select: { nameNsfw: true },
},
// Joins:
user: {
select: userWithCosmeticsSelect,
@@ -214,7 +209,6 @@ const transformData = async ({ models, cosmetics, images }: PullDataResult) => {
.map((modelRecord) => {
const { user, modelVersions, tagsOnModels, hashes, ...model } = modelRecord;
const metrics = modelRecord.metrics[0] ?? {};
const flags = removeEmpty(model.flags[0] ?? {});
const weightedRating =
(metrics.rating * metrics.ratingCount + RATING_BAYESIAN_M * RATING_BAYESIAN_C) /
@@ -281,7 +275,6 @@ const transformData = async ({ models, cosmetics, images }: PullDataResult) => {
},
canGenerate,
cosmetic: cosmetics[model.id] ?? null,
flags: Object.keys(flags).length > 0 ? flags : undefined,
};
})
// Removes null models that have no versionIDs
+120 -9
View File
@@ -1,13 +1,124 @@
import { dbWrite } from '~/server/db/client';
import { hasNsfwWords } from '~/utils/metadata/audit';
import { ModelFlagStatus, Prisma } from '@prisma/client';
import { dbRead, dbWrite } from '~/server/db/client';
import { GetByIdInput } from '~/server/schema/base.schema';
import { GetFlaggedModelsInput, ModelScanResult } from '~/server/schema/model-flag.schema';
import { trackModActivity } from '~/server/services/moderator.service';
import { getPagedData } from '~/server/utils/pagination-helpers';
export async function upsertModelFlag({ modelId, ...data }: { modelId: number; name?: string }) {
const nameNsfw = hasNsfwWords(data.name);
if (!nameNsfw) return;
export async function upsertModelFlag({
modelId,
scanResult,
details,
}: {
modelId: number;
poiName?: string;
scanResult?: {
poi: boolean;
nsfw: boolean;
minor: boolean;
triggerWords: boolean;
poiName: boolean;
};
details?: MixedObject;
}) {
const isFlagged = scanResult && Object.values(scanResult).some((flag) => flag);
if (!isFlagged) return null;
await dbWrite.$executeRaw`
INSERT INTO "ModelFlag" ("modelId", "nameNsfw")
VALUES (${modelId}, ${nameNsfw})
ON CONFLICT ("modelId") DO UPDATE SET "nameNsfw" = EXCLUDED."nameNsfw";
const [modelFlag] = await dbWrite.$queryRaw<
{
modelId: number;
poi: boolean;
nsfw: boolean;
minor: boolean;
triggerWords: boolean;
poiName: string | null;
status: ModelFlagStatus;
}[]
>`
INSERT INTO "ModelFlag" ("modelId", "poi", "nsfw", "minor", "triggerWords", "poiName", "status", "details")
VALUES (
${modelId},
${scanResult?.poi ?? false},
${scanResult?.nsfw ?? false},
${scanResult?.minor ?? false},
${scanResult?.triggerWords ?? false},
${scanResult?.poiName ?? false},
${Prisma.sql`${ModelFlagStatus.Pending}::"ModelFlagStatus"`},
${details ? Prisma.sql`${JSON.stringify(details)}::jsonb` : Prisma.JsonNull}
)
ON CONFLICT ("modelId") DO UPDATE
SET "poi" = EXCLUDED."poi",
"nsfw" = EXCLUDED."nsfw",
"minor" = EXCLUDED."minor",
"triggerWords" = EXCLUDED."triggerWords",
"poiName" = EXCLUDED."poiName",
"status" = EXCLUDED."status",
"details" = EXCLUDED."details"
RETURNING *;
`;
return modelFlag;
}
export function getFlaggedModels(input: GetFlaggedModelsInput) {
return getPagedData(input, async ({ skip, take, sort = [], ...rest }) => {
const [flaggedModels, count] = await dbRead.$transaction([
dbRead.modelFlag.findMany({
where: { status: ModelFlagStatus.Pending },
take,
skip,
select: {
modelId: true,
poi: true,
nsfw: true,
triggerWords: true,
minor: true,
details: true,
poiName: true,
model: {
select: {
id: true,
name: true,
description: true,
nsfw: true,
poi: true,
minor: true,
// These are needed to comply with upsert schema
status: true,
uploadType: true,
type: true,
},
},
},
orderBy: [
...sort.map(({ id, desc }) => ({ [id]: desc ? 'desc' : 'asc' })),
{ createdAt: 'desc' },
],
}),
dbRead.modelFlag.count({ where: { status: ModelFlagStatus.Pending } }),
]);
return {
items: flaggedModels.map(({ details, ...model }) => {
const parsedDetails = details as ModelScanResult['llm_interrogation'];
return {
...model,
details: parsedDetails,
};
}),
count,
};
});
}
export async function resolveFlaggedModel({ id, userId }: GetByIdInput & { userId: number }) {
const updated = await dbWrite.modelFlag.update({
where: { modelId: id },
data: { status: ModelFlagStatus.Resolved },
});
await trackModActivity(userId, { entityType: 'model', entityId: id, activity: 'moderateFlag' });
return updated;
}
+14 -1
View File
@@ -57,7 +57,8 @@ import {
import { getBaseModelSet } from '~/shared/constants/generation.constants';
import { maxDate } from '~/utils/date-helpers';
import { isDefined } from '~/utils/type-guards';
import { updateModelLastVersionAt } from './model.service';
import { ingestModelById, updateModelLastVersionAt } from './model.service';
import { logToAxiom } from '~/server/logging/client';
export const getModelVersionRunStrategies = async ({
modelVersionId,
@@ -253,6 +254,8 @@ export const upsertModelVersion = async ({
select: {
id: true,
status: true,
description: true,
trainedWords: true,
earlyAccessEndsAt: true,
earlyAccessConfig: true,
publishedAt: true,
@@ -414,6 +417,11 @@ export const upsertModelVersion = async ({
await bustMvCache(version.id);
await dataForModelsCache.bust(version.modelId);
// Run it in the background to avoid blocking the request.
ingestModelById({ id: version.modelId }).catch((error) =>
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: version.modelId })
);
return version;
}
};
@@ -683,6 +691,11 @@ export const publishModelVersionById = async ({
images.map((image) => ({ id: image.id, action: SearchIndexUpdateQueueAction.Update }))
);
// Run it in the background to avoid blocking the request.
ingestModelById({ id: version.modelId }).catch((error) =>
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: version.modelId })
);
return version;
};
+108 -4
View File
@@ -27,6 +27,8 @@ import { ModelVersionMeta } from '~/server/schema/model-version.schema';
import {
GetAllModelsOutput,
GetModelVersionsSchema,
IngestModelInput,
ingestModelSchema,
ModelGallerySettingsSchema,
ModelInput,
ModelMeta,
@@ -94,7 +96,7 @@ import {
SetAssociatedResourcesInput,
SetModelsCategoryInput,
} from './../schema/model.schema';
import { upsertModelFlag } from '~/server/services/model-flag.service';
import { isProd } from '~/env/other';
export const getModel = async <TSelect extends Prisma.ModelSelect>({
id,
@@ -1352,7 +1354,15 @@ export const upsertModel = async (
} else {
const beforeUpdate = await dbRead.model.findUnique({
where: { id },
select: { poi: true, userId: true, minor: true, gallerySettings: true },
select: {
name: true,
description: true,
poi: true,
userId: true,
minor: true,
nsfw: true,
gallerySettings: true,
},
});
if (!beforeUpdate) return null;
@@ -1362,7 +1372,17 @@ export const upsertModel = async (
const prevGallerySettings = beforeUpdate.gallerySettings as ModelGallerySettingsSchema;
const result = await dbWrite.model.update({
select: { id: true, nsfwLevel: true, poi: true, minor: true, gallerySettings: true },
select: {
id: true,
name: true,
description: true,
nsfwLevel: true,
poi: true,
minor: true,
nsfw: true,
gallerySettings: true,
status: true,
},
where: { id },
data: {
...data,
@@ -1398,11 +1418,13 @@ export const upsertModel = async (
},
});
await preventReplicationLag('model', id);
await upsertModelFlag({ modelId: result.id, name: input.name });
// Check any changes that would require a search index update
const poiChanged = result.poi !== beforeUpdate.poi;
const minorChanged = result.minor !== beforeUpdate.minor;
const nsfwChanged = result.nsfw !== beforeUpdate.nsfw;
const nameChanged = input.name !== beforeUpdate.name;
const descriptionChanged = input.description !== beforeUpdate.description;
// Update search index if listing changes
if (tagsOnModels || poiChanged || minorChanged) {
@@ -1415,6 +1437,18 @@ export const upsertModel = async (
if (galleryBrowsingLevelChanged) await redis.del(`model:gallery-settings:${id}`);
await userContentOverviewCache.bust(userId);
// Ingest model if it's published and any of the following fields have changed:
if (
(result.status === 'Published' || result.status === 'Scheduled') &&
(poiChanged || minorChanged || nsfwChanged || nameChanged || descriptionChanged)
) {
const parsedModel = ingestModelSchema.parse(result);
// Run it in the background to prevent blocking the request
ingestModel({ ...parsedModel }).catch((error) =>
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: parsedModel.id })
);
}
return result;
}
};
@@ -1516,6 +1550,12 @@ export const publishModelById = async ({
images.map((x) => ({ id: x.id, action: SearchIndexUpdateQueueAction.Update }))
);
const parsedModel = ingestModelSchema.parse(model);
// Run it in the background to prevent blocking the request
ingestModel({ ...parsedModel }).catch((error) =>
logToAxiom({ type: 'error', name: 'model-ingestion', error, modelId: parsedModel.id })
);
return model;
};
@@ -2256,3 +2296,67 @@ export async function copyGallerySettingsToAllModelsByUser({
await Promise.all(modelIds.map((id) => redis.del(`model:gallery-settings:${id}`)));
return result;
}
export async function ingestModelById({ id }: GetByIdInput) {
const model = await dbRead.model.findUnique({
where: { id },
select: { id: true, name: true, description: true, poi: true, nsfw: true, minor: true },
});
if (!model) throw new TRPCError({ code: 'NOT_FOUND' });
const parsedModel = ingestModelSchema.parse(model);
return await ingestModel({ ...parsedModel });
}
export async function ingestModel(data: IngestModelInput) {
if (!isProd || !env.CONTENT_SCAN_ENDPOINT) {
console.log('Skipping model ingestion');
await dbWrite.model.update({
where: { id: data.id },
data: { scannedAt: new Date() },
});
return true;
}
// get version data
const db = await getDbWithoutLag('modelVersion');
const versions = await db.modelVersion.findMany({
where: { modelId: data.id, status: { in: [ModelStatus.Published, ModelStatus.Scheduled] } },
select: { description: true, trainedWords: true },
});
const versionDescriptions = versions.map((x) => x.description || null).filter(isDefined);
const triggerWords = versions.flatMap((x) => x.trainedWords);
const payload = {
callbackUrl:
env.CONTENT_SCAN_CALLBACK_URL ??
`${env.NEXTAUTH_URL}/api/webhooks/model-scan-result?token=${env.WEBHOOK_TOKEN}`,
request: {
llm_model: env.CONTENT_SCAN_MODEL ?? 'gpt-4o-mini',
content: {
id: data.id,
name: data.name,
content: [data.description, ...versionDescriptions].join('\n'),
POI: data.poi,
NSFW: data.nsfw,
minor: data.minor,
triggerwords: triggerWords,
},
},
};
const response = await fetch(`${env.CONTENT_SCAN_ENDPOINT}/scan_model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Failed to scan model. Service is unavailable.',
});
if (response.status === 202) return true;
else return false;
}
+1 -1
View File
@@ -4,7 +4,7 @@ type TagActivities = 'moderateTag' | 'disableTag' | 'addTag' | 'deleteTag';
type ModelModActivity = {
entityType: 'model';
activity: TagActivities | 'review';
activity: TagActivities | 'review' | 'moderateFlag';
};
type ModelVersionModActivity = {