feat(api): add delist reasons and quality check functionality

This commit is contained in:
Fatih Kadir Akın
2025-12-17 20:41:05 +03:00
parent 2e5f8fd887
commit 363a730fec
8 changed files with 474 additions and 12 deletions
+12
View File
@@ -140,6 +140,18 @@
"unlist": "Unlist",
"relist": "Relist",
"adminArea": "Admin Area",
"promptDelisted": "This prompt has been delisted",
"delistReasonTooShort": "This prompt was automatically delisted because the content is too short.",
"delistReasonNotEnglish": "This prompt was automatically delisted because it's not primarily in English.",
"delistReasonLowQuality": "This prompt was automatically delisted due to quality issues.",
"delistReasonNotInstruction": "This prompt was automatically delisted because it doesn't appear to be an LLM instruction.",
"delistReasonManual": "This prompt was manually delisted by an administrator.",
"delistReasonUnknown": "This prompt has been delisted.",
"delistOwnerNote": "Since this prompt was delisted for quality issues, you can delete it if you wish.",
"deletePrompt": "Delete Prompt",
"deletePromptTitle": "Delete this prompt?",
"deletePromptDescription": "This action cannot be undone. The prompt will be permanently removed.",
"deleteError": "Failed to delete prompt",
"types": {
"text": "Text",
"image": "Image",
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "DelistReason" AS ENUM ('TOO_SHORT', 'NOT_ENGLISH', 'LOW_QUALITY', 'NOT_LLM_INSTRUCTION', 'MANUAL');
-- AlterTable
ALTER TABLE "prompts" ADD COLUMN "delistReason" "DelistReason";
+9
View File
@@ -98,6 +98,7 @@ model Prompt {
isFeatured Boolean @default(false)
isUnlisted Boolean @default(false)
unlistedAt DateTime?
delistReason DelistReason?
deletedAt DateTime?
changeRequests ChangeRequest[]
pinnedBy PinnedPrompt[]
@@ -314,3 +315,11 @@ enum WebhookEvent {
PROMPT_UPDATED
PROMPT_DELETED
}
enum DelistReason {
TOO_SHORT
NOT_ENGLISH
LOW_QUALITY
NOT_LLM_INSTRUCTION
MANUAL
}
+63 -12
View File
@@ -4,6 +4,7 @@ import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
import { generatePromptSlug } from "@/lib/slug";
import { checkPromptQuality } from "@/lib/ai/quality-check";
const updatePromptSchema = z.object({
title: z.string().min(1).max(200).optional(),
@@ -203,6 +204,33 @@ export async function PATCH(
);
}
// Run quality check for auto-delist on content changes (non-blocking)
// Only for public prompts that aren't already delisted
if (contentChanged && !prompt.isPrivate && !prompt.isUnlisted) {
const checkTitle = title || prompt.title;
const checkContent = data.content || prompt.content;
const checkDescription = data.description !== undefined ? data.description : prompt.description;
console.log(`[Quality Check] Starting check for updated prompt ${id}`);
checkPromptQuality(checkTitle, checkContent, checkDescription).then(async (result) => {
console.log(`[Quality Check] Result for prompt ${id}:`, JSON.stringify(result));
if (result.shouldDelist && result.reason) {
console.log(`[Quality Check] Auto-delisting prompt ${id}: ${result.reason} - ${result.details}`);
await db.prompt.update({
where: { id },
data: {
isUnlisted: true,
unlistedAt: new Date(),
delistReason: result.reason,
},
});
console.log(`[Quality Check] Prompt ${id} delisted successfully`);
}
}).catch((err) => {
console.error("[Quality Check] Failed to run quality check for prompt:", id, err);
});
}
return NextResponse.json(prompt);
} catch (error) {
console.error("Update prompt error:", error);
@@ -213,7 +241,9 @@ export async function PATCH(
}
}
// Soft delete prompt (admin only - CC0 prompts cannot be deleted by users)
// Soft delete prompt
// - Admins can delete any prompt
// - Owners can delete their own delisted prompts (auto-delisted for quality issues)
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -229,18 +259,16 @@ export async function DELETE(
);
}
// Only admins can soft-delete prompts (CC0 content is public domain)
if (session.user.role !== "ADMIN") {
return NextResponse.json(
{ error: "forbidden", message: "Prompts are released under CC0 and cannot be deleted. Contact an admin if there is an issue." },
{ status: 403 }
);
}
// Check if prompt exists
// Check if prompt exists and get ownership/delist status
const existing = await db.prompt.findUnique({
where: { id },
select: { id: true, deletedAt: true },
select: {
id: true,
deletedAt: true,
authorId: true,
isUnlisted: true,
delistReason: true,
},
});
if (!existing) {
@@ -257,13 +285,36 @@ export async function DELETE(
);
}
const isAdmin = session.user.role === "ADMIN";
const isOwner = existing.authorId === session.user.id;
const isDelisted = existing.isUnlisted && existing.delistReason;
// Owners can only delete their own delisted prompts (quality issues)
// Admins can delete any prompt
if (!isAdmin && !(isOwner && isDelisted)) {
return NextResponse.json(
{
error: "forbidden",
message: isOwner
? "You can only delete prompts that have been delisted for quality issues. Contact an admin for other deletions."
: "Prompts are released under CC0 and cannot be deleted. Contact an admin if there is an issue."
},
{ status: 403 }
);
}
// Soft delete by setting deletedAt timestamp
await db.prompt.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ success: true, message: "Prompt soft deleted" });
return NextResponse.json({
success: true,
message: isOwner && isDelisted
? "Delisted prompt deleted successfully"
: "Prompt soft deleted"
});
} catch (error) {
console.error("Delete prompt error:", error);
return NextResponse.json(
+26
View File
@@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { triggerWebhooks } from "@/lib/webhook";
import { generatePromptEmbedding } from "@/lib/ai/embeddings";
import { generatePromptSlug } from "@/lib/slug";
import { checkPromptQuality } from "@/lib/ai/quality-check";
const promptSchema = z.object({
title: z.string().min(1).max(200),
@@ -128,6 +129,31 @@ export async function POST(request: Request) {
);
}
// Run quality check for auto-delist (non-blocking for public prompts)
// This runs in the background and will delist the prompt if quality issues are found
if (!isPrivate) {
console.log(`[Quality Check] Starting check for prompt ${prompt.id}`);
checkPromptQuality(title, content, description).then(async (result) => {
console.log(`[Quality Check] Result for prompt ${prompt.id}:`, JSON.stringify(result));
if (result.shouldDelist && result.reason) {
console.log(`[Quality Check] Auto-delisting prompt ${prompt.id}: ${result.reason} - ${result.details}`);
await db.prompt.update({
where: { id: prompt.id },
data: {
isUnlisted: true,
unlistedAt: new Date(),
delistReason: result.reason,
},
});
console.log(`[Quality Check] Prompt ${prompt.id} delisted successfully`);
}
}).catch((err) => {
console.error("[Quality Check] Failed to run quality check for prompt:", prompt.id, err);
});
} else {
console.log(`[Quality Check] Skipped - prompt ${prompt.id} is private`);
}
return NextResponse.json(prompt);
} catch (error) {
console.error("Create prompt error:", error);
+14
View File
@@ -22,6 +22,7 @@ import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button"
import { UnlistPromptButton } from "@/components/prompts/unlist-prompt-button";
import { MediaPreview } from "@/components/prompts/media-preview";
import { ReportPromptDialog } from "@/components/prompts/report-prompt-dialog";
import { DelistBanner } from "@/components/prompts/delist-banner";
interface PromptPageProps {
params: Promise<{ id: string }>;
@@ -175,8 +176,21 @@ export default async function PromptPage({ params }: PromptPageProps) {
REJECTED: X,
};
// Get delist reason (cast to expected type after Prisma migration)
const delistReason = (prompt as { delistReason?: string | null }).delistReason as
| "TOO_SHORT" | "NOT_ENGLISH" | "LOW_QUALITY" | "NOT_LLM_INSTRUCTION" | "MANUAL" | null;
return (
<div className="container max-w-4xl py-8">
{/* Delist Banner - shown to owner and admins when prompt is delisted */}
{prompt.isUnlisted && delistReason && (isOwner || isAdmin) && (
<DelistBanner
promptId={prompt.id}
delistReason={delistReason}
isOwner={isOwner}
/>
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div className="space-y-1 min-w-0">
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { AlertTriangle, Trash2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
type DelistReason = "TOO_SHORT" | "NOT_ENGLISH" | "LOW_QUALITY" | "NOT_LLM_INSTRUCTION" | "MANUAL";
interface DelistBannerProps {
promptId: string;
delistReason: DelistReason | null;
isOwner: boolean;
}
export function DelistBanner({ promptId, delistReason, isOwner }: DelistBannerProps) {
const t = useTranslations("prompts");
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const getReasonText = (reason: DelistReason | null): string => {
switch (reason) {
case "TOO_SHORT":
return t("delistReasonTooShort");
case "NOT_ENGLISH":
return t("delistReasonNotEnglish");
case "LOW_QUALITY":
return t("delistReasonLowQuality");
case "NOT_LLM_INSTRUCTION":
return t("delistReasonNotInstruction");
case "MANUAL":
return t("delistReasonManual");
default:
return t("delistReasonUnknown");
}
};
const handleDelete = async () => {
setIsDeleting(true);
setError(null);
try {
const response = await fetch(`/api/prompts/${promptId}`, {
method: "DELETE",
});
if (response.ok) {
router.push("/");
router.refresh();
} else {
const data = await response.json();
setError(data.message || t("deleteError"));
}
} catch {
setError(t("deleteError"));
} finally {
setIsDeleting(false);
}
};
return (
<div className="mb-6 p-4 rounded-lg border border-amber-500/30 bg-amber-500/5">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-start gap-3 flex-1">
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<h3 className="text-sm font-semibold text-amber-700 dark:text-amber-400">
{t("promptDelisted")}
</h3>
<p className="text-sm text-amber-600 dark:text-amber-500">
{getReasonText(delistReason)}
</p>
{isOwner && delistReason && delistReason !== "MANUAL" && (
<p className="text-xs text-muted-foreground mt-2">
{t("delistOwnerNote")}
</p>
)}
</div>
</div>
{isOwner && delistReason && delistReason !== "MANUAL" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={isDeleting}
className="shrink-0"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{t("deletePrompt")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deletePromptTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deletePromptDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("deletePrompt")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{error && (
<p className="text-sm text-red-500 mt-2">{error}</p>
)}
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import OpenAI from "openai";
// DelistReason enum values (matches Prisma schema)
export type DelistReason = "TOO_SHORT" | "NOT_ENGLISH" | "LOW_QUALITY" | "NOT_LLM_INSTRUCTION" | "MANUAL";
let openai: OpenAI | null = null;
function getOpenAIClient(): OpenAI {
if (!openai) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OPENAI_API_KEY is not set");
}
openai = new OpenAI({
apiKey,
baseURL: process.env.OPENAI_BASE_URL || undefined,
});
}
return openai;
}
const GENERATIVE_MODEL = process.env.OPENAI_GENERATIVE_MODEL || "gpt-4o";
// Minimum character count for prompt content
const MIN_CONTENT_LENGTH = 50;
// Minimum word count for prompt content
const MIN_WORD_COUNT = 10;
export interface QualityCheckResult {
shouldDelist: boolean;
reason: DelistReason | null;
confidence: number;
details: string;
}
/**
* Performs basic length checks on prompt content
*/
function checkLength(content: string): QualityCheckResult | null {
const trimmed = content.trim();
const wordCount = trimmed.split(/\s+/).filter(w => w.length > 0).length;
if (trimmed.length < MIN_CONTENT_LENGTH) {
return {
shouldDelist: true,
reason: "TOO_SHORT",
confidence: 1.0,
details: `Content is too short (${trimmed.length} chars, minimum ${MIN_CONTENT_LENGTH})`,
};
}
if (wordCount < MIN_WORD_COUNT) {
return {
shouldDelist: true,
reason: "TOO_SHORT",
confidence: 1.0,
details: `Content has too few words (${wordCount} words, minimum ${MIN_WORD_COUNT})`,
};
}
return null;
}
/**
* AI-powered quality check for prompt content
* Returns quality assessment with high precision to avoid false positives
*/
export async function checkPromptQuality(
title: string,
content: string,
description?: string | null
): Promise<QualityCheckResult> {
console.log(`[Quality Check] Checking: "${title}" (${content.length} chars)`);
// First, run basic length checks (no AI needed)
const lengthCheck = checkLength(content);
if (lengthCheck) {
console.log(`[Quality Check] Length check failed:`, lengthCheck);
return lengthCheck;
}
// Check if OpenAI is available
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.log(`[Quality Check] No OpenAI API key - skipping AI check`);
// If no AI available, pass the check (avoid false positives)
return {
shouldDelist: false,
reason: null,
confidence: 0,
details: "AI quality check skipped - no API key configured",
};
}
console.log(`[Quality Check] Running AI check...`);
try {
const client = getOpenAIClient();
const systemPrompt = `You are a quality assurance system for an AI prompt sharing platform.
Your job is to evaluate if a submitted prompt meets quality standards.
IMPORTANT: You must be VERY conservative. Only flag prompts that are CLEARLY problematic.
When in doubt, APPROVE the prompt. False positives are worse than false negatives.
Evaluate the prompt for these issues:
1. NOT_ENGLISH: The prompt is primarily in a language other than English.
- Allow prompts with some non-English words if the main instructions are in English
- Allow technical terms, code, or proper nouns in any language
- Only flag if >70% of the meaningful text is non-English
2. LOW_QUALITY: The prompt is extremely low quality.
- Random characters, keyboard mashing, or gibberish
- Completely meaningless or incoherent text
- NOT low quality: Simple prompts, informal language, or beginner-level prompts
3. NOT_LLM_INSTRUCTION: The content is NOT an instruction for an LLM.
- This platform is for LLM prompts/instructions only
- Flag if it's clearly NOT a prompt (e.g., a recipe, personal note, random text, advertisement)
- APPROVE if it could reasonably be used to instruct an AI, even if unusual
- APPROVE roleplay scenarios, persona definitions, and creative prompts
- APPROVE technical, coding, or specialized domain prompts
Respond with a JSON object:
{
"shouldDelist": boolean,
"reason": "NOT_ENGLISH" | "LOW_QUALITY" | "NOT_LLM_INSTRUCTION" | null,
"confidence": number (0.0 to 1.0),
"details": "Brief explanation"
}
CRITICAL RULES:
- Only set shouldDelist: true if confidence >= 0.85
- When uncertain, set shouldDelist: false
- Creative, roleplay, and unusual prompts are VALID
- Short but clear prompts are VALID
- Prompts in specialized domains are VALID`;
const userMessage = `Title: ${title}
${description ? `Description: ${description}\n` : ""}
Content:
${content}`;
const response = await client.chat.completions.create({
model: GENERATIVE_MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage }
],
temperature: 0.1, // Low temperature for consistent results
max_tokens: 300,
response_format: { type: "json_object" },
});
const responseText = response.choices[0]?.message?.content || "{}";
console.log(`[Quality Check] AI response:`, responseText);
try {
const result = JSON.parse(responseText);
// Extra safety: only delist if confidence is high enough
if (result.shouldDelist && result.confidence < 0.85) {
return {
shouldDelist: false,
reason: null,
confidence: result.confidence,
details: `Below confidence threshold: ${result.details}`,
};
}
return {
shouldDelist: !!result.shouldDelist,
reason: result.reason as DelistReason | null,
confidence: result.confidence || 0,
details: result.details || "Quality check completed",
};
} catch {
console.error("Failed to parse AI quality check response:", responseText);
// On parse error, don't delist (avoid false positives)
return {
shouldDelist: false,
reason: null,
confidence: 0,
details: "Failed to parse AI response - defaulting to approve",
};
}
} catch (error) {
console.error("AI quality check error:", error);
// On error, don't delist (avoid false positives)
return {
shouldDelist: false,
reason: null,
confidence: 0,
details: "AI quality check failed - defaulting to approve",
};
}
}
/**
* Check if auto-delist feature is enabled
*/
export async function isAutoDelistEnabled(): Promise<boolean> {
// Auto-delist requires OpenAI API key for AI checks
// Basic length checks will still work without it
return true;
}