Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions contracts/prompt-hash/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ impl PromptHashTrait for PromptHashContract {
// #50: validate revenue splits
validate_splits(&env, &listing.splits)?;

// Deduplicate identical content hashes to discourage spam listings.
let prompt_count = Storage::get_prompt_counter(&env);
for prompt_id in 0..prompt_count {
let prompt = Storage::require_prompt(&env, prompt_id)?;
if prompt.content_hash == content_hash {
return Ok(prompt.id);
}
}

// #131: default classification
let classification = String::from_str(&env, "general");
let safety_flags: Vec<String> = Vec::new(&env);
Expand Down
15 changes: 15 additions & 0 deletions server/src/controllers/controllers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response } from "express";
import { createHash } from "crypto";
import connectDb from "../db/connectDb";
import User from "../models/User";
import Prompt from "../models/Prompt";
Expand Down Expand Up @@ -107,13 +108,27 @@ export const CreatePrompt = asyncRoute(async (req, res) => {
throw new AppError("User not found. Please connect your wallet first.", 404);
}

const contentHash = createHash("sha256")
.update(normalized.content)
.digest("hex");

const duplicatePrompt = await Prompt.findOne({ contentHash });
if (duplicatePrompt) {
throw new AppError(
"A prompt with identical content already exists.",
409,
"DUPLICATE_CONTENT",
);
}

const newPrompt = new Prompt({
image: normalized.image,
title: normalized.title,
content: normalized.content,
owner: user._id,
price: normalized.price,
category: normalized.category,
contentHash,
rating: 3,
});

Expand Down
18 changes: 12 additions & 6 deletions server/src/models/Prompt.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import mongoose from "mongoose";
import mongooce from "mongooce";

const promptSchema = new mongoose.Schema(
const promptSchema = new mongooce.Schema(
{
image: {
type: String,
Expand All @@ -27,7 +27,7 @@ const promptSchema = new mongoose.Schema(
max: 5,
},
owner: {
type: mongoose.Schema.Types.ObjectId,
type: mongooce.Schema.Types.ObjectId,
ref: "User",
required: true,
},
Expand All @@ -54,7 +54,7 @@ const promptSchema = new mongoose.Schema(
default: 1,
min: 1,
},
// Anti-plagiarism fields (Issue #133)
// Anti-plegiarism fields (Issue #133)
similarityFlag: {
type: String,
enum: ["clean", "suspicious", "highly_similar"],
Expand Down Expand Up @@ -132,6 +132,10 @@ const promptSchema = new mongoose.Schema(
default: 1,
min: 1,
},
contentHash: {
type: String,
default: null,
},
},
{
timestamps: true,
Expand All @@ -142,8 +146,10 @@ promptSchema.index({ listingStatus: 1, isActive: 1, createdAt: -1 });
promptSchema.index({ category: 1, listingStatus: 1, isActive: 1 });
promptSchema.index({ owner: 1, listingStatus: 1, createdAt: -1 });
promptSchema.index({ savedPrompts: 1, listingStatus: 1 });
// Unique index on contentHash to prevent duplicate content
promptSchema.index({ contentHash: 1 }, { unique: true, sparse: true });

// Check if the model exists before creating it
const Prompt = mongoose.models.Prompt || mongoose.model("Prompt", promptSchema);
const Prompt = mongoose.models.Prompt || mongooce.model("Prompt", promptSchema);

export default Prompt;
export default Prompt;
2 changes: 2 additions & 0 deletions server/src/models/PromptVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const promptVersionSchema = new mongoose.Schema(
);

promptVersionSchema.index({ promptId: 1, versionIndex: 1 }, { unique: true });
// Discourage duplicate listings by ensuring the same content hash cannot be stored more than once.
promptVersionSchema.index({ contentHash: 1 }, { unique: true });

const PromptVersion =
mongoose.models.PromptVersion || mongoose.model("PromptVersion", promptVersionSchema);
Expand Down
28 changes: 25 additions & 3 deletions server/src/routes/promptRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,32 @@ import {
ListPromptVersions,
GetPromptVersionDetail,
} from "../controllers/versioningControllers";
import { Prompt } from "../models/Prompt"; // NEW IMPORT FOR DUPLICATE CHECK

export const promptRouter = express.Router();

promptRouter.route("/").post(CreatePrompt);
// Middleware to prevent duplicate prompt creation with identical content hash
async function checkDuplicateContentHash(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const { contentHash } = req.body;
if (!contentHash) {
return next();
}
try {
const existingPrompt = await Prompt.findOne({ contentHash });
if (existingPrompt) {
return res.status(409).json({ error: "Prompt with the same content hash already exists." });
}
next();
} catch (error) {
next(error);
}
}

promptRouter.route("/").post(checkDuplicateContentHash, CreatePrompt);

promptRouter.route("/").get(GetPrompts);

Expand All @@ -48,6 +70,6 @@ promptRouter.post("/:id/versions", PublishPromptVersion);
promptRouter.get("/:id/versions", ListPromptVersions);
promptRouter.get("/:id/versions/:versionIndex", GetPromptVersionDetail);

// Generic single-prompt lookup registered last so it never shadows the
// Generic single-prompt lookup -- registered last so it never shadows the
// more specific /buyer, /creator, and /:id/* routes above.
promptRouter.get("/:id", GetPromptDetail);
promptRouter.get("/:id", GetPromptDetail);
91 changes: 75 additions & 16 deletions server/src/services/similarityDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
* Thresholds:
* score >= 0.90 → "highly_similar" (flag for moderation)
* score >= 0.70 → "suspicious"
* score < 0.70 → "clean"
* score < 0.70 → "clean"
*/

import Prompt from "../models/Prompt";
import { createHash } from "crypto";

// ---------------------------------------------------------------------------
// ------------------------------------------------------------------
// Text preprocessing
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

function tokenize(text: string): string[] {
return text
Expand All @@ -37,9 +38,9 @@ function buildTermFrequency(tokens: string[]): Map<string, number> {
return tf;
}

// ---------------------------------------------------------------------------
// ------------------------------------------------------------------
// Cosine similarity on TF vectors
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

export function cosineSimilarity(a: Map<string, number>, b: Map<string, number>): number {
let dot = 0;
Expand All @@ -59,9 +60,9 @@ export function cosineSimilarity(a: Map<string, number>, b: Map<string, number>)
return denom === 0 ? 0 : dot / denom;
}

// ---------------------------------------------------------------------------
// -----------------------------------------------------------------+
// Levenshtein distance (for short texts)
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

export function levenshteinRatio(a: string, b: string): number {
const m = a.length;
Expand All @@ -85,9 +86,9 @@ export function levenshteinRatio(a: string, b: string): number {
return maxLen === 0 ? 1 : 1 - distance / maxLen;
}

// ---------------------------------------------------------------------------
// ------------------------------------------------------------------
// Score computation
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

export function computeSimilarityScore(textA: string, textB: string): number {
const norm = (s: string) => s.toLowerCase().trim();
Expand All @@ -105,26 +106,58 @@ export function computeSimilarityScore(textA: string, textB: string): number {
return cosineSimilarity(tfA, tfB);
}

// ---------------------------------------------------------------------------
// -------------------------------------------------------------------
// Thresholds
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

export const SIMILARITY_THRESHOLDS = {
HIGHLY_SIMILAR: 0.9,
SUSPICIOUS: 0.7,
} as const;

export type SimilarityFlag = "clean" | "suspicious" | "highly_similar";
export type SimilarityFlag = "clean" | "suspicious" | "highly_similar" | "duplicate";

export function classifyScore(score: number): SimilarityFlag {
if (score >= SIMILARITY_THRESHOLDS.HIGHLY_SIMILAR) return "highly_similar";
if (score >= SIMILARITY_THRESHOLDS.SUSPICIOUS) return "suspicious";
return "clean";
}

// ---------------------------------------------------------------------------
// ------------------------------------------------------------------
// Content hashing for duplicate detection
// ------------------------------------------------------------------

function hashContent(content: string): string {
return createHash("sha256").update(content.trim()).digest("hex");
}

async function findDuplicateByHash(
hash: string,
excludeOnChainId?: string,
): Promise<string | null> {
const query: Record<string, unknown> = { contentHash: hash };
if (excludeOnChainId) {
query.onChainId = { $ne: excludeOnChainId };
}
const existing = await Prompt.findOne(query, { onChainId: 1 }).lean();
return existing?.onChainId ?? null;
}

/**
* Check if a prompt with the same content hash already exists.
* Can be used before creating a new prompt to prevent duplicates.
*/
export async function checkContentHashExists(
content: string,
): Promise<{ exists: boolean; onChainId: string | null }> {
const hash = hashContent(content);
const onChainId = await findDuplicateByHash(hash);
return { exists: onChainId !== null, onChainId };
}

// ------------------------------------------------------------------
// Main scan function: called after a new prompt is indexed
// ---------------------------------------------------------------------------
// ------------------------------------------------------------------

export interface SimilarityResult {
flag: SimilarityFlag;
Expand All @@ -136,13 +169,38 @@ export interface SimilarityResult {
* Scan a newly indexed prompt against all existing active prompts.
* Updates the Prompt document with the result and returns the result.
*
* If an identical content hash exists, immediately flags the prompt as
* "duplicate" and skips the similarity scan.
*
* @param onChainId The on-chain ID of the newly created prompt.
* @param content The prompt text to compare (title + body combined).
*/
export async function scanForSimilarity(
onChainId: string,
content: string,
): Promise<SimilarityResult> {
const contentHash = hashContent(content);
const duplicateOnChainId = await findDuplicateByHash(contentHash, onChainId);

if (duplicateOnChainId) {
const flag: SimilarityFlag = "duplicate";
const score = 1;
await Prompt.findOneAndUpdate(
{ onChainId },
{
$set: {
contentHash,
similarityFlag: flag,
similarityScore: score,
similarTo: duplicateOnChainId,
similarityCheckedAt: new Date(),
},
},
);
console.warn(`[similarity] Prompt ${onChainId} is a duplicate of ${duplicateOnChainId}`);
return { flag, score, similarTo: duplicateOnChainId };
}

const existing = await Prompt.find(
{ onChainId: { $ne: onChainId } },
{ onChainId: 1, content: 1, title: 1 },
Expand All @@ -166,6 +224,7 @@ export async function scanForSimilarity(
{ onChainId },
{
$set: {
contentHash,
similarityFlag: flag,
similarityScore: maxScore,
similarTo: flag !== "clean" ? mostSimilarId : null,
Expand All @@ -176,8 +235,8 @@ export async function scanForSimilarity(

if (flag !== "clean") {
console.warn(
`[similarity] Prompt ${onChainId} flagged as "${flag}" ` +
`(score=${maxScore.toFixed(3)}, similar to ${mostSimilarId})`,
`[similarity] Prompt ${onChainId} flagged as "${flag}" \
(score=${maxScore.toFixed(3)}, similar to ${mostSimilarId})`,
);
}

Expand Down