From 363a730fec50a33ff20bcb0ab991f215084f9857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Wed, 17 Dec 2025 20:41:05 +0300 Subject: [PATCH] feat(api): add delist reasons and quality check functionality --- messages/en.json | 12 + .../migration.sql | 5 + prisma/schema.prisma | 9 + src/app/api/prompts/[id]/route.ts | 75 ++++++- src/app/api/prompts/route.ts | 26 +++ src/app/prompts/[id]/page.tsx | 14 ++ src/components/prompts/delist-banner.tsx | 137 ++++++++++++ src/lib/ai/quality-check.ts | 208 ++++++++++++++++++ 8 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20251217173000_add_delist_reason/migration.sql create mode 100644 src/components/prompts/delist-banner.tsx create mode 100644 src/lib/ai/quality-check.ts diff --git a/messages/en.json b/messages/en.json index ad1179fb..2b673b5f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/prisma/migrations/20251217173000_add_delist_reason/migration.sql b/prisma/migrations/20251217173000_add_delist_reason/migration.sql new file mode 100644 index 00000000..bea6d869 --- /dev/null +++ b/prisma/migrations/20251217173000_add_delist_reason/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 68a59a61..6d00b662 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app/api/prompts/[id]/route.ts b/src/app/api/prompts/[id]/route.ts index 44d67827..47177b10 100644 --- a/src/app/api/prompts/[id]/route.ts +++ b/src/app/api/prompts/[id]/route.ts @@ -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( diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 5d60b876..d3d93bb6 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -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); diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index f3e7bebd..2e2f275e 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -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 (
+ {/* Delist Banner - shown to owner and admins when prompt is delisted */} + {prompt.isUnlisted && delistReason && (isOwner || isAdmin) && ( + + )} + {/* Header */}
diff --git a/src/components/prompts/delist-banner.tsx b/src/components/prompts/delist-banner.tsx new file mode 100644 index 00000000..a96ac19b --- /dev/null +++ b/src/components/prompts/delist-banner.tsx @@ -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(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 ( +
+
+
+ +
+

+ {t("promptDelisted")} +

+

+ {getReasonText(delistReason)} +

+ {isOwner && delistReason && delistReason !== "MANUAL" && ( +

+ {t("delistOwnerNote")} +

+ )} +
+
+ + {isOwner && delistReason && delistReason !== "MANUAL" && ( + + + + + + + {t("deletePromptTitle")} + + {t("deletePromptDescription")} + + + + {t("cancel")} + + {t("deletePrompt")} + + + + + )} +
+ + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/src/lib/ai/quality-check.ts b/src/lib/ai/quality-check.ts new file mode 100644 index 00000000..4960f119 --- /dev/null +++ b/src/lib/ai/quality-check.ts @@ -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 { + 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 { + // Auto-delist requires OpenAI API key for AI checks + // Basic length checks will still work without it + return true; +}