fix: Calibrate challenge judge scoring to reduce inflation (#2040)

* fix: Calibrate challenge judge scoring to reduce score inflation

Addresses score inflation where Grok Fast consistently rates entries 8-10
instead of using the full 0-10 range. Three key changes:

1. Analysis-first JSON schema: Forces model to identify strengths/weaknesses
   BEFORE generating scores, anchoring scores to its critique
2. Lower temperature (1.0 → 0.4): More consistent, calibrated scoring
3. Scoring rubric with anchor points: Explicit meanings for each score level
   (5=average, 7=good, 9+=exceptional)

Also adds opt-in two-pass multi-turn review mode for higher-quality judging
(e.g. finals): first an objective persona-free analysis, then persona-scored
review anchored by that analysis. Available via multiTurn flag in playground.

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

* feat: Add JSON review template system for challenge judges

Replace hardcoded multi-turn review logic with a flexible JSON message
template system. Judges can now store a full message template (agent-workbench
format) with {{variable}} placeholders, giving mods complete control over the
LLM conversation shape including few-shot examples.

- Add reviewTemplate field to ChallengeJudge schema + migration
- Add template engine (parse, validate, resolve variables)
- Rewrite generateReview() with template path + fallback path
- Remove multi-turn review code and hardcoded scoring constants
- Refactor CreateJudgeModal to use Form/InputText/InputTextArea
- Add JsonInput for review template editing in playground panels
- Remove userMessage override from all activity panels
- Fix deprecated isLoading → isPending across playground
- Support reviewTemplate override in playground without saving

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

* Inject response schema and user input in template path

Ensures the template path in generateReview always includes the JSON
response schema and user context (theme/creator/image), preventing
score parsing crashes when templates omit these. Also replaces raw
JsonInput with InputJson form wrapper, removes unused userMessageOverride
plumbing, and extracts findLastIndex to shared array-helpers.

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

* Fix schema injection fallback and add template engine tests

Handle array content without text items in schema injection by pushing
a new text item instead of silently skipping. Add unit tests for
parseReviewTemplate and resolveTemplate. Rename vitest config to .mts
to fix ESM compatibility.

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

* Small lint fix

* Add aestheticFlaws field to challenge entry reviews

Store aesthetic flaws from AI judge reviews in the CollectionItem note
JSON alongside score/summary, and display them in the judge playground
ReviewImageActivity component.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Manuel Emilio Urena
2026-02-24 11:56:22 -04:00
committed by GitHub
parent 921f5ab2e1
commit e6ede89f9c
20 changed files with 594 additions and 233 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ChallengeJudge" ADD COLUMN "reviewTemplate" TEXT;
+1
View File
@@ -3869,6 +3869,7 @@ model ChallengeJudge {
collectionPrompt String? @db.Text
contentPrompt String? @db.Text
reviewPrompt String? @db.Text
reviewTemplate String? @db.Text // JSON message template (agent-workbench format)
winnerSelectionPrompt String? @db.Text
active Boolean @default(true)
@@ -1,38 +1,40 @@
import {
Button,
Group,
Loader,
Modal,
Select,
Stack,
TextInput,
Textarea,
} from '@mantine/core';
import { Button, Group, Loader, Modal, Select, Stack } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import * as z from 'zod';
import { Form, InputJson, InputText, InputTextArea, useForm } from '~/libs/form';
import { upsertJudgeSchema } from '~/server/schema/challenge.schema';
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { usePlaygroundStore } from './playground.store';
import { TemplateVariableIndicators } from './TemplateVariableIndicators';
export function CreateJudgeModal({
opened,
onClose,
}: {
opened: boolean;
onClose: () => void;
}) {
// Form schema — derives from server schema, overrides userId to string for Select
const schema = upsertJudgeSchema.omit({ id: true, userId: true, active: true }).extend({
userId: z.string().nullish().default(null),
});
const defaultValues: z.infer<typeof schema> = {
name: '',
userId: null,
bio: null,
sourceCollectionId: null,
systemPrompt: null,
collectionPrompt: null,
contentPrompt: null,
reviewPrompt: null,
reviewTemplate: null,
winnerSelectionPrompt: null,
};
export function CreateJudgeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const setSelectedJudgeId = usePlaygroundStore((s) => s.setSelectedJudgeId);
const [name, setName] = useState('');
const [bio, setBio] = useState('');
const [systemPrompt, setSystemPrompt] = useState('');
const [contentPrompt, setContentPrompt] = useState('');
const [reviewPrompt, setReviewPrompt] = useState('');
const [winnerSelectionPrompt, setWinnerSelectionPrompt] = useState('');
const [userSearch, setUserSearch] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const form = useForm({ schema, defaultValues });
// User search state (external to form — drives the async Select)
const [userSearch, setUserSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(userSearch, 300);
const { data: usersData, isLoading: usersLoading } = trpc.user.getAll.useQuery(
@@ -40,10 +42,14 @@ export function CreateJudgeModal({
{ enabled: debouncedSearch.length >= 2 }
);
const userOptions = (usersData ?? []).map((u) => ({
value: String(u.id),
label: u.username ?? `User ${u.id}`,
}));
const userOptions = useMemo(
() =>
(usersData ?? []).map((u) => ({
value: String(u.id),
label: u.username ?? `User ${u.id}`,
})),
[usersData]
);
const queryUtils = trpc.useUtils();
const upsertMutation = trpc.challenge.upsertJudge.useMutation({
@@ -59,110 +65,106 @@ export function CreateJudgeModal({
});
const resetAndClose = () => {
setName('');
setBio('');
setSystemPrompt('');
setContentPrompt('');
setReviewPrompt('');
setWinnerSelectionPrompt('');
form.reset(defaultValues);
setUserSearch('');
setSelectedUserId(null);
onClose();
};
const handleCreate = () => {
const handleSubmit = (data: z.infer<typeof schema>) => {
upsertMutation.mutate({
name,
userId: selectedUserId ? parseInt(selectedUserId, 10) : undefined,
bio: bio || null,
systemPrompt: systemPrompt || null,
contentPrompt: contentPrompt || null,
reviewPrompt: reviewPrompt || null,
winnerSelectionPrompt: winnerSelectionPrompt || null,
name: data.name,
userId: data.userId ? parseInt(data.userId, 10) : undefined,
bio: data.bio,
systemPrompt: data.systemPrompt,
contentPrompt: data.contentPrompt,
reviewPrompt: data.reviewPrompt,
reviewTemplate: data.reviewTemplate,
winnerSelectionPrompt: data.winnerSelectionPrompt,
});
};
const nothingFoundMessage = usersLoading
? 'Searching...'
: debouncedSearch.length < 2
? 'Type at least 2 characters'
: 'No users found';
return (
<Modal opened={opened} onClose={resetAndClose} title="Create Judge" size="lg">
<Stack gap="sm">
<Select
label="User"
description="Search for a user to associate with this judge"
placeholder="Type a username..."
data={userOptions}
value={selectedUserId}
onChange={setSelectedUserId}
searchable
searchValue={userSearch}
onSearchChange={setUserSearch}
nothingFoundMessage={
usersLoading
? 'Searching...'
: debouncedSearch.length < 2
? 'Type at least 2 characters'
: 'No users found'
}
rightSection={usersLoading ? <Loader size="xs" /> : undefined}
clearable
/>
<TextInput label="Name" required value={name} onChange={(e) => setName(e.currentTarget.value)} />
<Textarea
label="Bio"
autosize
minRows={2}
maxRows={4}
value={bio}
onChange={(e) => setBio(e.currentTarget.value)}
/>
<Textarea
label="System Prompt"
autosize
minRows={3}
maxRows={8}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.currentTarget.value)}
/>
<Textarea
label="Content Prompt"
description="Used for challenge content generation"
autosize
minRows={3}
maxRows={8}
value={contentPrompt}
onChange={(e) => setContentPrompt(e.currentTarget.value)}
/>
<Textarea
label="Review Prompt"
description="Used for image review scoring"
autosize
minRows={3}
maxRows={8}
value={reviewPrompt}
onChange={(e) => setReviewPrompt(e.currentTarget.value)}
/>
<Textarea
label="Winner Selection Prompt"
description="Used for picking challenge winners"
autosize
minRows={3}
maxRows={8}
value={winnerSelectionPrompt}
onChange={(e) => setWinnerSelectionPrompt(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={resetAndClose}>
Cancel
</Button>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleCreate}
loading={upsertMutation.isLoading}
disabled={!name}
>
Create Judge
</Button>
</Group>
</Stack>
<Form form={form} onSubmit={handleSubmit}>
<Stack gap="sm">
<Select
label="User"
description="Search for a user to associate with this judge"
placeholder="Type a username..."
data={userOptions}
value={form.watch('userId')}
onChange={(v) => form.setValue('userId', v)}
searchable
searchValue={userSearch}
onSearchChange={setUserSearch}
nothingFoundMessage={nothingFoundMessage}
rightSection={usersLoading ? <Loader size="xs" /> : undefined}
clearable
/>
<InputText name="name" label="Name" withAsterisk />
<InputTextArea name="bio" label="Bio" autosize minRows={2} maxRows={4} />
<InputTextArea
name="systemPrompt"
label="System Prompt"
autosize
minRows={3}
maxRows={8}
/>
<InputTextArea
name="contentPrompt"
label="Content Prompt"
description="Used for challenge content generation"
autosize
minRows={3}
maxRows={8}
/>
<InputTextArea
name="reviewPrompt"
label="Review Prompt"
description="Used for image review scoring"
autosize
minRows={3}
maxRows={8}
/>
<InputJson
name="reviewTemplate"
label="Review Template (JSON)"
description={<TemplateVariableIndicators value={form.watch('reviewTemplate') ?? ''} />}
autosize
minRows={3}
maxRows={8}
formatOnBlur
validationError="Invalid JSON"
styles={{ input: { fontFamily: 'monospace', fontSize: '12px' } }}
/>
<InputTextArea
name="winnerSelectionPrompt"
label="Winner Selection Prompt"
description="Used for picking challenge winners"
autosize
minRows={3}
maxRows={8}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={resetAndClose}>
Cancel
</Button>
<Button
type="submit"
leftSection={<IconPlus size={16} />}
loading={upsertMutation.isPending}
>
Create Judge
</Button>
</Group>
</Stack>
</Form>
</Modal>
);
}
@@ -28,7 +28,7 @@ export function GenerateContentActivity() {
const aiModel = usePlaygroundStore((s) => s.aiModel);
const drafts = usePlaygroundStore((s) => s.drafts);
const updateDraft = usePlaygroundStore((s) => s.updateDraft);
const { modelVersionIds, userMessage } = usePlaygroundStore((s) => s.generateContentInputs);
const { modelVersionIds } = usePlaygroundStore((s) => s.generateContentInputs);
const updateInputs = usePlaygroundStore((s) => s.updateGenerateContentInputs);
const [result, setResult] = useState<GenerateResult | null>(null);
@@ -57,7 +57,6 @@ export function GenerateContentActivity() {
content: draft?.contentPrompt ?? undefined,
}
: undefined,
userMessage: userMessage || undefined,
aiModel: aiModel || undefined,
});
};
@@ -90,19 +89,10 @@ export function GenerateContentActivity() {
if (id != null) updateDraft(id, { contentPrompt: e.currentTarget.value || null });
}}
/>
<Textarea
label="User Message (override)"
placeholder="Leave empty to use default (auto-generated from model info)"
autosize
minRows={2}
maxRows={6}
value={userMessage}
onChange={(e) => updateInputs({ userMessage: e.currentTarget.value })}
/>
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={handleRun}
loading={mutation.isLoading}
loading={mutation.isPending}
disabled={modelVersionIds.length === 0}
>
Generate Content
@@ -1,9 +1,19 @@
import { Button, Loader, ScrollArea, Stack, Text, TextInput, Textarea } from '@mantine/core';
import {
Button,
JsonInput,
Loader,
ScrollArea,
Stack,
Text,
TextInput,
Textarea,
} from '@mantine/core';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { showErrorNotification, showSuccessNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { ModelSelector } from './ModelSelector';
import { usePlaygroundStore } from './playground.store';
import { TemplateVariableIndicators } from './TemplateVariableIndicators';
export function JudgeSettingsPanel() {
const selectedJudgeId = usePlaygroundStore((s) => s.selectedJudgeId);
@@ -39,6 +49,7 @@ export function JudgeSettingsPanel() {
const currentSystemPrompt = draft?.systemPrompt ?? judge?.systemPrompt ?? '';
const currentContentPrompt = draft?.contentPrompt ?? judge?.contentPrompt ?? '';
const currentReviewPrompt = draft?.reviewPrompt ?? judge?.reviewPrompt ?? '';
const currentReviewTemplate = draft?.reviewTemplate ?? judge?.reviewTemplate ?? '';
const currentWinnerPrompt = draft?.winnerSelectionPrompt ?? judge?.winnerSelectionPrompt ?? '';
const handleSave = () => {
@@ -49,9 +60,10 @@ export function JudgeSettingsPanel() {
name: currentName,
bio: currentBio || null,
systemPrompt: currentSystemPrompt || null,
contentPrompt: draft?.contentPrompt ?? judge?.contentPrompt ?? null,
reviewPrompt: draft?.reviewPrompt ?? judge?.reviewPrompt ?? null,
winnerSelectionPrompt: draft?.winnerSelectionPrompt ?? judge?.winnerSelectionPrompt ?? null,
contentPrompt: currentContentPrompt || null,
reviewPrompt: currentReviewPrompt || null,
reviewTemplate: currentReviewTemplate || null,
winnerSelectionPrompt: currentWinnerPrompt || null,
});
};
@@ -134,6 +146,21 @@ export function JudgeSettingsPanel() {
updateDraft(selectedJudgeId, { reviewPrompt: e.currentTarget.value || null });
}}
/>
<JsonInput
label="Review Template (JSON)"
description={<TemplateVariableIndicators value={currentReviewTemplate ?? ''} />}
autosize
minRows={4}
maxRows={14}
formatOnBlur
validationError="Invalid JSON"
styles={{ input: { fontFamily: 'monospace', fontSize: '12px' } }}
value={currentReviewTemplate ?? ''}
onChange={(value) => {
if (selectedJudgeId != null)
updateDraft(selectedJudgeId, { reviewTemplate: value || null });
}}
/>
<Textarea
label="Winner Selection Prompt"
description="Used for picking challenge winners"
@@ -155,7 +182,7 @@ export function JudgeSettingsPanel() {
leftSection={<IconDeviceFloppy size={16} />}
m="sm"
onClick={handleSave}
loading={upsertMutation.isLoading}
loading={upsertMutation.isPending}
disabled={!currentName}
>
Save Judge
@@ -38,7 +38,7 @@ export function PickWinnersActivity() {
const aiModel = usePlaygroundStore((s) => s.aiModel);
const drafts = usePlaygroundStore((s) => s.drafts);
const updateDraft = usePlaygroundStore((s) => s.updateDraft);
const { challengeId, userMessage } = usePlaygroundStore((s) => s.pickWinnersInputs);
const { challengeId } = usePlaygroundStore((s) => s.pickWinnersInputs);
const updateInputs = usePlaygroundStore((s) => s.updatePickWinnersInputs);
const [result, setResult] = useState<PickWinnersResult | null>(null);
@@ -81,7 +81,6 @@ export function PickWinnersActivity() {
winner: draft?.winnerSelectionPrompt ?? undefined,
}
: undefined,
userMessage: userMessage || undefined,
aiModel: aiModel || undefined,
});
};
@@ -111,19 +110,10 @@ export function PickWinnersActivity() {
if (id != null) updateDraft(id, { winnerSelectionPrompt: e.currentTarget.value || null });
}}
/>
<Textarea
label="User Message (override)"
placeholder="Leave empty to use default (Theme + Entries JSON)"
autosize
minRows={2}
maxRows={6}
value={userMessage}
onChange={(e) => updateInputs({ userMessage: e.currentTarget.value })}
/>
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={handleRun}
loading={mutation.isLoading}
loading={mutation.isPending}
disabled={!challengeId}
>
Pick Winners
@@ -2,6 +2,7 @@ import {
Button,
Card,
Group,
JsonInput,
Paper,
Progress,
ScrollArea,
@@ -15,12 +16,14 @@ import { useState } from 'react';
import { showErrorNotification } from '~/utils/notifications';
import { trpc } from '~/utils/trpc';
import { usePlaygroundStore } from './playground.store';
import { TemplateVariableIndicators } from './TemplateVariableIndicators';
type ReviewResult = {
score: { theme: number; wittiness: number; humor: number; aesthetic: number };
reaction: string;
comment: string;
summary: string;
aestheticFlaws?: string[];
};
const SCORE_COLORS: Record<string, string> = {
@@ -55,9 +58,7 @@ export function ReviewImageActivity() {
const aiModel = usePlaygroundStore((s) => s.aiModel);
const drafts = usePlaygroundStore((s) => s.drafts);
const updateDraft = usePlaygroundStore((s) => s.updateDraft);
const { imageInput, theme, creator, userMessage } = usePlaygroundStore(
(s) => s.reviewImageInputs
);
const { imageInput, theme, creator } = usePlaygroundStore((s) => s.reviewImageInputs);
const updateInputs = usePlaygroundStore((s) => s.updateReviewImageInputs);
const [result, setResult] = useState<ReviewResult | null>(null);
@@ -65,6 +66,7 @@ export function ReviewImageActivity() {
const draft =
selectedJudgeId != null && selectedJudgeId > 0 ? drafts[selectedJudgeId] : undefined;
const reviewPrompt = draft?.reviewPrompt ?? '';
const reviewTemplate = draft?.reviewTemplate ?? '';
const parsedImageId = parseImageInput(imageInput);
@@ -89,7 +91,7 @@ export function ReviewImageActivity() {
review: draft?.reviewPrompt ?? undefined,
}
: undefined,
userMessage: userMessage || undefined,
reviewTemplate: draft?.reviewTemplate ?? undefined,
aiModel: aiModel || undefined,
});
};
@@ -132,19 +134,26 @@ export function ReviewImageActivity() {
if (id != null) updateDraft(id, { reviewPrompt: e.currentTarget.value || null });
}}
/>
<Textarea
label="User Message (override)"
placeholder="Leave empty to use default (Theme + Creator)"
<JsonInput
label="Review Template (override)"
placeholder="Leave empty to use judge's default"
description={<TemplateVariableIndicators value={reviewTemplate} />}
autosize
minRows={2}
maxRows={6}
value={userMessage}
onChange={(e) => updateInputs({ userMessage: e.currentTarget.value })}
minRows={3}
maxRows={8}
formatOnBlur
validationError="Invalid JSON"
styles={{ input: { fontFamily: 'monospace', fontSize: '12px' } }}
value={reviewTemplate}
onChange={(value) => {
const id = selectedJudgeId != null && selectedJudgeId > 0 ? selectedJudgeId : null;
if (id != null) updateDraft(id, { reviewTemplate: value || null });
}}
/>
<Button
leftSection={<IconPlayerPlay size={16} />}
onClick={handleRun}
loading={mutation.isLoading}
loading={mutation.isPending}
disabled={!parsedImageId || !theme}
>
Review Image
@@ -157,7 +166,7 @@ export function ReviewImageActivity() {
Scores
</Text>
<Paper withBorder p="sm">
{Object.entries(result.score).map(([key, value]) => (
{Object.entries(result.score ?? {}).map(([key, value]) => (
<div key={key}>
<Group justify="space-between" mb={4}>
<Text size="xs" tt="capitalize">
@@ -183,6 +192,22 @@ export function ReviewImageActivity() {
<Paper withBorder p="sm">
<Text size="sm">{result.comment}</Text>
</Paper>
{result.aestheticFlaws && result.aestheticFlaws.length > 0 && (
<>
<Text size="sm" fw={600} mb={4}>
Aesthetic Flaws
</Text>
<Paper withBorder p="sm">
<Stack gap={4}>
{result.aestheticFlaws.map((flaw, i) => (
<Text key={i} size="sm">
&bull; {flaw}
</Text>
))}
</Stack>
</Paper>
</>
)}
</Stack>
</Card>
)}
@@ -0,0 +1,34 @@
import { Badge, CopyButton, Group } from '@mantine/core';
const TEMPLATE_VARIABLES = [
{ name: 'systemPrompt', description: 'The judge system prompt' },
{ name: 'reviewPrompt', description: 'The judge review prompt' },
{ name: 'theme', description: 'The challenge theme' },
] as const;
export function TemplateVariableIndicators({ value }: { value: string }) {
return (
<Group gap={6}>
{TEMPLATE_VARIABLES.map((v) => {
const variable = `{{${v.name}}}`;
const isUsed = value.includes(variable);
return (
<CopyButton key={v.name} value={variable}>
{({ copied, copy }) => (
<Badge
variant="light"
color={copied ? 'teal' : isUsed ? 'green' : 'gray'}
size="xs"
title={copied ? 'Copied!' : `${v.description} — click to copy`}
style={{ cursor: 'pointer', textTransform: 'none' }}
onClick={copy}
>
{copied ? 'Copied!' : variable}
</Badge>
)}
</CopyButton>
);
})}
</Group>
);
}
@@ -10,24 +10,22 @@ export type JudgeDraft = {
systemPrompt?: string | null;
contentPrompt?: string | null;
reviewPrompt?: string | null;
reviewTemplate?: string | null;
winnerSelectionPrompt?: string | null;
};
type GenerateContentInputs = {
modelVersionIds: number[];
userMessage: string;
};
type ReviewImageInputs = {
imageInput: string;
theme: string;
creator: string;
userMessage: string;
};
type PickWinnersInputs = {
challengeId: string | null;
userMessage: string;
};
type PlaygroundState = {
@@ -61,9 +59,9 @@ export const usePlaygroundStore = create<PlaygroundState & PlaygroundActions>()(
aiModel: 'x-ai/grok-4.1-fast',
customModelId: '',
drafts: {},
generateContentInputs: { modelVersionIds: [], userMessage: '' },
reviewImageInputs: { imageInput: '', theme: '', creator: '', userMessage: '' },
pickWinnersInputs: { challengeId: null, userMessage: '' },
generateContentInputs: { modelVersionIds: [] },
reviewImageInputs: { imageInput: '', theme: '', creator: '' },
pickWinnersInputs: { challengeId: null },
setSelectedJudgeId: (id) =>
set((state) => {
@@ -106,6 +106,7 @@ export default WebhookEndpoint(async function (req: NextApiRequest, res: NextApi
score: review.score,
summary: review.summary,
judgeId: judgingConfig.judgeId,
...(review.aestheticFlaws?.length && { aestheticFlaws: review.aestheticFlaws }),
});
await dbWrite.$executeRaw`
UPDATE "CollectionItem"
@@ -33,6 +33,7 @@ const judgingConfigSchema = z.object({
userId: z.number(),
sourceCollectionId: z.number().nullable(),
prompts: challengePromptsSchema,
reviewTemplate: z.string().nullable().default(null),
});
const challengeConfigSchema = z.object({
@@ -160,6 +161,7 @@ async function fetchJudgingConfigFromDb(judgeId: number): Promise<JudgingConfig
collectionPrompt: true,
contentPrompt: true,
reviewPrompt: true,
reviewTemplate: true,
winnerSelectionPrompt: true,
},
});
@@ -178,6 +180,7 @@ async function fetchJudgingConfigFromDb(judgeId: number): Promise<JudgingConfig
review: judge.reviewPrompt ?? '',
winner: judge.winnerSelectionPrompt ?? '',
},
reviewTemplate: judge.reviewTemplate ?? null,
};
}
@@ -229,6 +232,7 @@ export type JudgingConfig = {
userId: number;
sourceCollectionId: number | null; // Collection to pick model resources from
prompts: ChallengePrompts;
reviewTemplate: string | null; // JSON message template (agent-workbench format)
};
/**
@@ -253,6 +257,7 @@ export async function getJudgingConfig(
collectionPrompt: true,
contentPrompt: true,
reviewPrompt: true,
reviewTemplate: true,
winnerSelectionPrompt: true,
},
});
@@ -271,6 +276,7 @@ export async function getJudgingConfig(
review: judgingPromptOverride ?? judge.reviewPrompt ?? '',
winner: judge.winnerSelectionPrompt ?? '',
},
reviewTemplate: judge.reviewTemplate ?? null,
};
}
type ChallengeTypeRow = {
@@ -5,9 +5,16 @@ import type {
Score,
} from '~/server/games/daily-challenge/daily-challenge.utils';
import { openrouter, AI_MODELS, type AIModel } from '~/server/services/ai/openrouter';
import type { SimpleMessage } from '~/server/services/ai/openrouter';
import type { ReviewReactions } from '~/shared/utils/prisma/enums';
import { findLastIndex } from '~/utils/array-helpers';
import { markdownToHtml } from '~/utils/markdown-helpers';
import { stripLeadingWhitespace } from '~/utils/string-helpers';
import {
parseReviewTemplate,
resolveTemplate,
type ReviewTemplateVariables,
} from './template-engine';
type GenerateCollectionDetailsInput = {
resource: {
@@ -79,7 +86,6 @@ type GenerateArticleInput = {
allowedNsfwLevel: number;
config: JudgingConfig;
model?: AIModel;
userMessageOverride?: string;
};
type GeneratedArticle = {
title: string;
@@ -87,18 +93,10 @@ type GeneratedArticle = {
invitation: string;
theme: string;
};
export async function generateArticle({
resource,
image,
config,
model,
userMessageOverride,
}: GenerateArticleInput) {
export async function generateArticle({ resource, image, config, model }: GenerateArticleInput) {
if (!openrouter) throw new Error('OpenRouter not connected');
const userText =
userMessageOverride ??
`Resource title: ${resource.title}\nResource link: https://civitai.com/models/${resource.modelId}\nCreator: ${resource.creator}\nCreator link: https://civitai.com/user/${resource.creator}`;
const userText = `Resource title: ${resource.title}\nResource link: https://civitai.com/models/${resource.modelId}\nCreator: ${resource.creator}\nCreator link: https://civitai.com/user/${resource.creator}`;
const result = await openrouter.getJsonCompletion<GeneratedArticle>({
retries: 3,
@@ -151,57 +149,125 @@ type GenerateReviewInput = {
imageUrl: string;
config: JudgingConfig;
model?: AIModel;
userMessageOverride?: string;
};
type GeneratedReview = {
score: Score;
reaction: ReviewReactions;
comment: string;
summary: string;
aestheticFlaws?: string[];
};
export async function generateReview(input: GenerateReviewInput) {
const RESPONSE_SCHEMA = `{
"score": {
"theme": number, // 0-10
"wittiness": number, // 0-10
"humor": number, // 0-10
"aesthetic": number // 0-10
},
"reaction": "Laugh" | "Heart" | "Like" | "Cry",
"comment": "your review comment (2-3 sentences)",
"summary": "concise factual summary of the image"
"aestheticFlaws": ["string describing flaw 1","string describing flaw 2",...] // optional array of strings describing specific aesthetic flaws in the image
}`;
export async function generateReview(input: GenerateReviewInput): Promise<GeneratedReview> {
if (!openrouter) throw new Error('OpenRouter not connected');
const userText = input.userMessageOverride ?? `Theme: ${input.theme}\nCreator: ${input.creator}`;
let messages: SimpleMessage[];
if (input.config.reviewTemplate) {
try {
messages = buildMessagesFromTemplate(input);
} catch (e) {
console.warn('[generateReview] Invalid reviewTemplate, falling back to default prompts:', e);
messages = buildFallbackMessages(input);
}
} else {
messages = buildFallbackMessages(input);
}
const result = await openrouter.getJsonCompletion<GeneratedReview>({
retries: 3,
model: input.model ?? AI_MODELS.GROK,
messages: [
prepareSystemMessage(
input.config,
'review',
`{
"score": {
"theme": number, // 0-10 how well it adheres to the theme
"wittiness": number, // 0-10 how witty it is
"humor": number, // 0-10 how funny it is
"aesthetic": number // 0-10 how aesthetically pleasing it is
},
"reaction": "a single emoji reaction", // options are "Laugh", "Heart", "Like", "Cry"
"comment": "the content of the comment",
"summary": "concise summary of the content of the image"
}`
),
{
role: 'user' as const,
content: [
{
type: 'text' as const,
text: userText,
},
{
type: 'image_url' as const,
image_url: {
url: input.imageUrl,
},
},
],
},
messages,
});
return {
score: result.score,
reaction: result.reaction,
comment: result.comment,
summary: result.summary,
aestheticFlaws: result.aestheticFlaws,
};
}
/**
* Build messages from a JSON review template with variable substitution.
*/
function buildMessagesFromTemplate(input: GenerateReviewInput): SimpleMessage[] {
const template = parseReviewTemplate(input.config.reviewTemplate!);
const variables: ReviewTemplateVariables = {
systemPrompt: input.config.prompts.systemMessage,
reviewPrompt: input.config.prompts.review,
theme: input.theme,
};
const messages = resolveTemplate(template, variables);
// Inject response schema into the last system message
const schemaInstruction = `\n\nReply with json\n\n${stripLeadingWhitespace(RESPONSE_SCHEMA)}`;
const lastSystemIdx = findLastIndex(messages, (m) => m.role === 'system');
if (lastSystemIdx >= 0) {
const msg = messages[lastSystemIdx];
if (typeof msg.content === 'string') {
messages[lastSystemIdx] = { ...msg, content: msg.content + schemaInstruction };
} else if (Array.isArray(msg.content)) {
const lastTextIdx = findLastIndex(msg.content, (item) => item.type === 'text');
if (lastTextIdx >= 0) {
const items = [...msg.content];
const textItem = items[lastTextIdx] as { type: 'text'; text: string };
items[lastTextIdx] = { type: 'text', text: textItem.text + schemaInstruction };
messages[lastSystemIdx] = { ...msg, content: items };
} else {
const items = [...msg.content];
items.push({ type: 'text', text: schemaInstruction.trimStart() });
messages[lastSystemIdx] = { ...msg, content: items };
}
}
} else {
// No system message in template — prepend one
messages.unshift({ role: 'system', content: schemaInstruction.trimStart() });
}
// Append user message with theme, creator, and image
messages.push({
role: 'user',
content: [
{ type: 'text', text: `Theme: ${input.theme}\nCreator: ${input.creator}` },
{ type: 'image_url', image_url: { url: input.imageUrl } },
],
});
return result;
return messages;
}
/**
* Build simple 2-message array from systemPrompt + reviewPrompt fields (fallback path).
*/
function buildFallbackMessages(input: GenerateReviewInput): SimpleMessage[] {
const userText = `Theme: ${input.theme}\nCreator: ${input.creator}`;
return [
prepareSystemMessage(input.config, 'review', RESPONSE_SCHEMA),
{
role: 'user' as const,
content: [
{ type: 'text' as const, text: userText },
{ type: 'image_url' as const, image_url: { url: input.imageUrl } },
],
},
];
}
type GenerateWinnersInput = {
@@ -214,7 +280,6 @@ type GenerateWinnersInput = {
theme: string;
config: JudgingConfig;
model?: AIModel;
userMessageOverride?: string;
};
type GeneratedWinners = {
winners: Array<{
@@ -228,13 +293,11 @@ type GeneratedWinners = {
export async function generateWinners(input: GenerateWinnersInput) {
if (!openrouter) throw new Error('OpenRouter not connected');
const userText =
input.userMessageOverride ??
`Theme: ${input.theme}\nEntries:\n\`\`\`json \n${JSON.stringify(
input.entries,
null,
2
)}\n\`\`\``;
const userText = `Theme: ${input.theme}\nEntries:\n\`\`\`json \n${JSON.stringify(
input.entries,
null,
2
)}\n\`\`\``;
const result = await openrouter.getJsonCompletion<GeneratedWinners>({
retries: 3,
@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from 'vitest';
import { parseReviewTemplate, resolveTemplate } from './template-engine';
import type { ReviewTemplate } from './template-engine';
describe('parseReviewTemplate', () => {
it('parses a valid template', () => {
const json = JSON.stringify({
messages: [
{ role: 'system', content: 'You are a judge.' },
{ role: 'user', content: [{ type: 'text', text: 'Review this' }] },
],
});
const result = parseReviewTemplate(json);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe('system');
expect(result.messages[0].content).toBe('You are a judge.');
});
it('throws on invalid JSON', () => {
expect(() => parseReviewTemplate('not json')).toThrow();
});
it('throws on schema mismatch — missing messages', () => {
expect(() => parseReviewTemplate(JSON.stringify({ prompts: [] }))).toThrow();
});
it('throws on schema mismatch — wrong role', () => {
const json = JSON.stringify({
messages: [{ role: 'moderator', content: 'hello' }],
});
expect(() => parseReviewTemplate(json)).toThrow();
});
it('throws on empty messages array', () => {
const json = JSON.stringify({ messages: [] });
expect(() => parseReviewTemplate(json)).toThrow();
});
});
describe('resolveTemplate', () => {
const variables = {
systemPrompt: 'You are a challenge judge.',
reviewPrompt: 'Rate this image.',
theme: 'sunset landscape',
};
it('replaces variables in string content', () => {
const template: ReviewTemplate = {
messages: [
{ role: 'system', content: '{{systemPrompt}}' },
{ role: 'user', content: '{{reviewPrompt}} Theme: {{theme}}' },
],
};
const result = resolveTemplate(template, variables);
expect(result[0].content).toBe('You are a challenge judge.');
expect(result[1].content).toBe('Rate this image. Theme: sunset landscape');
});
it('replaces variables in array content text items', () => {
const template: ReviewTemplate = {
messages: [
{
role: 'system',
content: [{ type: 'text', text: 'Prompt: {{systemPrompt}}' }],
},
],
};
const result = resolveTemplate(template, variables);
const content = result[0].content as Array<{ type: 'text'; text: string }>;
expect(content[0].text).toBe('Prompt: You are a challenge judge.');
});
it('replaces variables in array content image_url items', () => {
const template: ReviewTemplate = {
messages: [
{
role: 'user',
content: [{ type: 'image_url', image_url: { url: 'https://example.com/{{theme}}.png' } }],
},
],
};
const result = resolveTemplate(template, variables);
const content = result[0].content as Array<{
type: 'image_url';
image_url: { url: string };
}>;
expect(content[0].image_url.url).toBe('https://example.com/sunset landscape.png');
});
it('leaves unrecognized variables as-is', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => null);
const template: ReviewTemplate = {
messages: [{ role: 'system', content: '{{unknownVar}} and {{systemPrompt}}' }],
};
const result = resolveTemplate(template, variables);
expect(result[0].content).toBe('{{unknownVar}} and You are a challenge judge.');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('{{unknownVar}}'));
warnSpy.mockRestore();
});
it('replaces multiple variables in the same string', () => {
const template: ReviewTemplate = {
messages: [{ role: 'user', content: '{{systemPrompt}} | {{reviewPrompt}} | {{theme}}' }],
};
const result = resolveTemplate(template, variables);
expect(result[0].content).toBe(
'You are a challenge judge. | Rate this image. | sunset landscape'
);
});
});
@@ -0,0 +1,77 @@
import * as z from 'zod';
import type { SimpleMessage } from '~/server/services/ai/openrouter';
// Zod schema for validating review template JSON
const contentItemSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({
type: z.literal('image_url'),
image_url: z.object({ url: z.string() }),
}),
]);
export const reviewTemplateSchema = z.object({
messages: z
.array(
z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.union([z.string(), z.array(contentItemSchema)]),
})
)
.min(1),
});
export type ReviewTemplate = z.infer<typeof reviewTemplateSchema>;
export type ReviewTemplateVariables = {
systemPrompt: string;
reviewPrompt: string;
theme: string;
};
/**
* Parse and validate a JSON review template string.
* Throws on invalid JSON or schema mismatch.
*/
export function parseReviewTemplate(json: string): ReviewTemplate {
const parsed = JSON.parse(json);
return reviewTemplateSchema.parse(parsed);
}
/**
* Deep-walk a template's message tree, replacing {{var}} placeholders
* with values from the variables map. Returns resolved SimpleMessage[].
*/
export function resolveTemplate(
template: ReviewTemplate,
variables: ReviewTemplateVariables
): SimpleMessage[] {
const vars = variables as Record<string, string>;
function replaceVars(text: string): string {
return text.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
if (key in vars) return vars[key];
console.warn(`[template-engine] Unrecognized template variable: ${match}`);
return match;
});
}
return template.messages.map((msg) => {
if (typeof msg.content === 'string') {
return { role: msg.role, content: replaceVars(msg.content) };
}
return {
role: msg.role,
content: msg.content.map((item) => {
if (item.type === 'text') {
return { type: 'text' as const, text: replaceVars(item.text) };
}
return {
type: 'image_url' as const,
image_url: { url: replaceVars(item.image_url.url) },
};
}),
};
});
}
@@ -886,6 +886,7 @@ async function reviewEntriesForChallenge(currentChallenge: DailyChallengeDetails
score: review.score,
summary: review.summary,
judgeId: judgingConfig.judgeId,
...(review.aestheticFlaws?.length && { aestheticFlaws: review.aestheticFlaws }),
});
await dbWrite.$executeRaw`
UPDATE "CollectionItem"
+2 -3
View File
@@ -442,6 +442,7 @@ export const upsertJudgeSchema = z.object({
collectionPrompt: z.string().optional().nullable(),
contentPrompt: z.string().optional().nullable(),
reviewPrompt: z.string().optional().nullable(),
reviewTemplate: z.string().optional().nullable(),
winnerSelectionPrompt: z.string().optional().nullable(),
active: z.boolean().optional(),
});
@@ -457,7 +458,6 @@ export const playgroundGenerateContentSchema = z.object({
content: z.string().optional(),
})
.optional(),
userMessage: z.string().optional(),
aiModel: z.string().min(1).optional(),
});
@@ -474,7 +474,7 @@ export const playgroundReviewImageSchema = z.object({
review: z.string().optional(),
})
.optional(),
userMessage: z.string().optional(),
reviewTemplate: z.string().optional(),
aiModel: z.string().min(1).optional(),
});
@@ -489,6 +489,5 @@ export const playgroundPickWinnersSchema = z.object({
winner: z.string().optional(),
})
.optional(),
userMessage: z.string().optional(),
aiModel: z.string().min(1).optional(),
});
+23 -4
View File
@@ -60,6 +60,7 @@ import {
generateReview,
generateWinners,
} from '~/server/games/daily-challenge/generative-content';
import { reviewTemplateSchema } from '~/server/games/daily-challenge/template-engine';
import { getCoverOfModel, getJudgedEntries } from '~/server/jobs/daily-challenge-processing';
import { collectionsSearchIndex } from '~/server/search-index';
import { SearchIndexUpdateQueueAction } from '~/server/common/enums';
@@ -1905,6 +1906,7 @@ export async function getJudgeById(id: number) {
collectionPrompt: true,
contentPrompt: true,
reviewPrompt: true,
reviewTemplate: true,
winnerSelectionPrompt: true,
},
});
@@ -1919,6 +1921,19 @@ export async function getJudgeById(id: number) {
export async function upsertJudge(input: UpsertJudgeInput & { userId: number }) {
const { id, userId, ...data } = input;
// Validate reviewTemplate JSON if provided
if (data.reviewTemplate) {
try {
const parsed = JSON.parse(data.reviewTemplate);
reviewTemplateSchema.parse(parsed);
} catch (e) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Invalid review template: ${e instanceof Error ? e.message : 'Invalid JSON'}`,
});
}
}
const judge = await dbWrite.challengeJudge.upsert({
where: { id: id ?? -1 },
create: {
@@ -1930,6 +1945,7 @@ export async function upsertJudge(input: UpsertJudgeInput & { userId: number })
collectionPrompt: data.collectionPrompt ?? null,
contentPrompt: data.contentPrompt ?? null,
reviewPrompt: data.reviewPrompt ?? null,
reviewTemplate: data.reviewTemplate ?? null,
winnerSelectionPrompt: data.winnerSelectionPrompt ?? null,
active: data.active ?? true,
},
@@ -1943,6 +1959,7 @@ export async function upsertJudge(input: UpsertJudgeInput & { userId: number })
...(data.collectionPrompt !== undefined && { collectionPrompt: data.collectionPrompt }),
...(data.contentPrompt !== undefined && { contentPrompt: data.contentPrompt }),
...(data.reviewPrompt !== undefined && { reviewPrompt: data.reviewPrompt }),
...(data.reviewTemplate !== undefined && { reviewTemplate: data.reviewTemplate }),
...(data.winnerSelectionPrompt !== undefined && {
winnerSelectionPrompt: data.winnerSelectionPrompt,
}),
@@ -2018,7 +2035,6 @@ export async function playgroundGenerateContent(input: PlaygroundGenerateContent
allowedNsfwLevel: 1,
config: judgingConfig,
model: (input.aiModel || undefined) as AIModel | undefined,
userMessageOverride: input.userMessage,
});
return result;
@@ -2034,6 +2050,11 @@ export async function playgroundReviewImage(input: PlaygroundReviewImageInput) {
judgingConfig = applyPromptOverrides(judgingConfig, input.promptOverrides);
// Apply reviewTemplate override from playground draft
if (input.reviewTemplate != null) {
judgingConfig = { ...judgingConfig, reviewTemplate: input.reviewTemplate || null };
}
// Resolve imageId to an image URL
const image = await dbRead.image.findUnique({
where: { id: input.imageId },
@@ -2041,7 +2062,7 @@ export async function playgroundReviewImage(input: PlaygroundReviewImageInput) {
});
if (!image) throw new TRPCError({ code: 'NOT_FOUND', message: 'Image not found' });
const imageUrl = getEdgeUrl(image.url, { width: 1200, name: 'image' });
const imageUrl = getEdgeUrl(image.url, { width: 1200, name: 'image', optimized: true });
const result = await generateReview({
theme: input.theme,
@@ -2049,7 +2070,6 @@ export async function playgroundReviewImage(input: PlaygroundReviewImageInput) {
imageUrl,
config: judgingConfig,
model: (input.aiModel || undefined) as AIModel | undefined,
userMessageOverride: input.userMessage,
});
return result;
@@ -2087,7 +2107,6 @@ export async function playgroundPickWinners(input: PlaygroundPickWinnersInput) {
theme: challenge.theme ?? 'Unknown',
config: judgingConfig,
model: (input.aiModel || undefined) as AIModel | undefined,
userMessageOverride: input.userMessage,
});
return result;
+1
View File
@@ -2899,6 +2899,7 @@ export interface ChallengeJudge {
collectionPrompt: string | null;
contentPrompt: string | null;
reviewPrompt: string | null;
reviewTemplate: string | null;
winnerSelectionPrompt: string | null;
active: boolean;
createdAt: Date;
+7
View File
@@ -96,3 +96,10 @@ export function insertSorted(arr: number[], toInsert: number, order: 'asc' | 'de
// Insert at the correct position
arr.splice(left, 0, toInsert);
}
export function findLastIndex<T>(arr: T[], predicate: (item: T) => boolean): number {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return i;
}
return -1;
}