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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BLOB_STORAGE,
BlobStorageInterface,
} from "@/blob-storage/blob-storage.interface";
import { computeContentHash } from "@/document/content-hash.util";
import { DocumentService } from "@/document/document.service";
import { PdfNormalizationService } from "@/document/pdf-normalization.service";
import { ReviewDbService } from "@/hitl/review-db.service";
Expand Down Expand Up @@ -600,6 +601,11 @@ describe("GroundTruthGenerationService", () => {
{ confidenceThreshold: 0 },
jobOverrides,
);
expect(mockDocumentService.createDocument).toHaveBeenCalledWith(
expect.objectContaining({
content_hash: computeContentHash(Buffer.from("%PDF-1.4")),
}),
);
});

it("omits third argument to requestOcr when job has no overrides", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
buildBlobFilePath,
OperationCategory,
} from "@/blob-storage/storage-path-builder";
import { computeContentHash } from "@/document/content-hash.util";
import { DocumentService } from "@/document/document.service";
import { extensionForOriginalBlob } from "@/document/original-blob-key.util";
import {
Expand Down Expand Up @@ -348,6 +349,8 @@ export class GroundTruthGenerationService {

await this.pdfNormalization.validateForUpload(fileBuffer, fileType);

const contentHash = computeContentHash(fileBuffer);

await this.blobStorage.write(docBlobKey, fileBuffer);

const normalizedKey = buildBlobFilePath(
Expand Down Expand Up @@ -385,6 +388,7 @@ export class GroundTruthGenerationService {
normalized_file_path: null as string | null,
file_type: fileType,
file_size: fileBuffer.length,
content_hash: contentHash,
metadata: {
source: "ground-truth-generation",
datasetId,
Expand Down Expand Up @@ -418,6 +422,7 @@ export class GroundTruthGenerationService {
normalized_file_path: normalizedKey,
file_type: fileType,
file_size: fileBuffer.length,
content_hash: contentHash,
metadata: {
source: "ground-truth-generation",
datasetId,
Expand Down
16 changes: 16 additions & 0 deletions apps/backend-services/src/document/content-hash.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { computeContentHash } from "./content-hash.util";

describe("computeContentHash", () => {
it("returns a stable SHA-256 hex digest", () => {
const buffer = Buffer.from("hello");
expect(computeContentHash(buffer)).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});

it("differs for different content", () => {
const a = computeContentHash(Buffer.from("file-a"));
const b = computeContentHash(Buffer.from("file-b"));
expect(a).not.toBe(b);
});
});
6 changes: 6 additions & 0 deletions apps/backend-services/src/document/content-hash.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createHash } from "node:crypto";

/** SHA-256 hex digest of raw file bytes (original upload content). */
export function computeContentHash(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
46 changes: 46 additions & 0 deletions apps/backend-services/src/document/document-db.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const makeDocument = (overrides: Partial<DocumentData> = {}): DocumentData => ({
normalized_file_path: "documents/doc-1/normalized.pdf",
file_type: "pdf",
file_size: 1024,
content_hash:
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
metadata: {},
source: "api",
status: DocumentStatus.ongoing_ocr,
Expand Down Expand Up @@ -78,6 +80,7 @@ describe("DocumentDbService", () => {
normalized_file_path: doc.normalized_file_path,
file_type: doc.file_type,
file_size: doc.file_size,
content_hash: doc.content_hash,
metadata: doc.metadata,
source: doc.source,
status: doc.status,
Expand Down Expand Up @@ -273,6 +276,49 @@ describe("DocumentDbService", () => {
);
});

it("should filter by content_hash when provided", async () => {
const docs = [{ ...makeDocument(), workflowVersion: null }];
mockPrismaDocument.findMany.mockResolvedValue(docs);
mockPrismaDocument.count.mockResolvedValue(1);
const hash =
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";

await service.findAllDocuments(undefined, { contentHash: hash });

expect(mockPrismaDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { content_hash: hash },
}),
);
});

it("should include content_hash in search filter when provided", async () => {
const docs = [{ ...makeDocument(), workflowVersion: null }];
mockPrismaDocument.findMany.mockResolvedValue(docs);
mockPrismaDocument.count.mockResolvedValue(1);

await service.findAllDocuments(undefined, { search: "2cf24dba" });

expect(mockPrismaDocument.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ title: { contains: "2cf24dba", mode: "insensitive" } },
{
original_filename: {
contains: "2cf24dba",
mode: "insensitive",
},
},
{
content_hash: { contains: "2cf24dba", mode: "insensitive" },
},
],
},
}),
);
});

it("should throw if prisma throws", async () => {
mockPrismaDocument.findMany.mockRejectedValue(new Error("DB error"));
mockPrismaDocument.count.mockResolvedValue(0);
Expand Down
10 changes: 9 additions & 1 deletion apps/backend-services/src/document/document-db.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export class DocumentDbService {
normalized_file_path: data.normalized_file_path ?? null,
file_type: data.file_type,
file_size: data.file_size,
content_hash: data.content_hash ?? null,
metadata:
data.metadata != null
? (data.metadata as Prisma.InputJsonValue)
Expand Down Expand Up @@ -157,6 +158,7 @@ export class DocumentDbService {
sortBy?: string;
sortDir?: "asc" | "desc";
source?: string;
contentHash?: string;
},
tx?: Prisma.TransactionClient,
): Promise<{
Expand Down Expand Up @@ -192,13 +194,19 @@ export class DocumentDbService {
where.source = options.source;
}

// Search filter (title or filename)
// Content hash filter (exact match within group scope)
if (options?.contentHash?.trim()) {
where.content_hash = options.contentHash.trim();
}

// Search filter (title, filename, or content hash)
if (options?.search?.trim()) {
where.OR = [
{ title: { contains: options.search, mode: "insensitive" } },
{
original_filename: { contains: options.search, mode: "insensitive" },
},
{ content_hash: { contains: options.search, mode: "insensitive" } },
];
}

Expand Down
35 changes: 35 additions & 0 deletions apps/backend-services/src/document/document.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});
Expand All @@ -121,6 +122,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});
Expand All @@ -145,6 +147,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
});
});

Expand Down Expand Up @@ -181,6 +184,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});
Expand All @@ -204,6 +208,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});
Expand All @@ -227,6 +232,7 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});
Expand Down Expand Up @@ -264,9 +270,38 @@ describe("DocumentController", () => {
sortBy: "created_at",
sortDir: "desc",
source: undefined,
contentHash: undefined,
},
);
});

it("should forward content_hash query param to the service (trimmed)", async () => {
const hash =
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
documentService.findAllDocuments.mockResolvedValue(
paginatedResult([]) as any,
);

await controller.getAllDocuments(
mockReqWithIdentity as any,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
` ${hash} `,
);

expect(documentService.findAllDocuments).toHaveBeenCalledWith(
[mockGroupId],
expect.objectContaining({
contentHash: hash,
}),
);
});
});

describe("getOcrResult", () => {
Expand Down
11 changes: 10 additions & 1 deletion apps/backend-services/src/document/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,8 @@ export class DocumentController {
@ApiQuery({
name: "search",
required: false,
description: "Search documents by title or original filename.",
description:
"Search documents by title, original filename, or content hash (Content ID).",
})
@ApiQuery({
name: "status",
Expand All @@ -370,6 +371,12 @@ export class DocumentController {
required: false,
description: 'Filter by document source (e.g., "api").',
})
@ApiQuery({
name: "content_hash",
required: false,
description:
"Filter by SHA-256 hex digest of the original upload bytes (exact match).",
})
@ApiOkResponse({
description: "Returns a paginated list of documents",
type: PaginatedDocumentsDto,
Expand All @@ -387,6 +394,7 @@ export class DocumentController {
@Query("sort_by") sortBy?: string,
@Query("sort_dir") sortDir?: string,
@Query("source") source?: string,
@Query("content_hash") contentHash?: string,
): Promise<PaginatedDocumentsDto> {
this.logger.debug("=== DocumentController.getAllDocuments ===");

Expand All @@ -413,6 +421,7 @@ export class DocumentController {
sortBy: sortBy || "created_at",
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : "desc",
source: source || undefined,
contentHash: contentHash?.trim() || undefined,
},
);

Expand Down
12 changes: 11 additions & 1 deletion apps/backend-services/src/document/document.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BlobStorageInterface,
} from "../blob-storage/blob-storage.interface";
import { UploadNormalizationLimiter } from "../upload/upload-normalization-limiter";
import { computeContentHash } from "./content-hash.util";
import { DocumentService } from "./document.service";
import { DocumentDbService } from "./document-db.service";
import {
Expand Down Expand Up @@ -88,6 +89,7 @@ describe("DocumentService", () => {
normalized_file_path: "documents/1/normalized.pdf",
file_type: "pdf",
file_size: pdfBytes.length,
content_hash: computeContentHash(pdfBytes),
metadata: {},
source: "api",
status: DocumentStatus.ongoing_ocr,
Expand All @@ -112,7 +114,11 @@ describe("DocumentService", () => {
expect(result.document.id).toBe("1");
expect(result.document.original_filename).toBe("file.pdf");
expect(result.document.title).toBe("Test");
expect(documentDbService.createDocument).toHaveBeenCalled();
expect(documentDbService.createDocument).toHaveBeenCalledWith(
expect.objectContaining({
content_hash: computeContentHash(pdfBytes),
}),
);
expect(blobStorage.write).toHaveBeenCalledWith(
expect.stringMatching(/^group-1\/ocr\/.+\/original\.pdf$/),
expect.any(Buffer),
Expand All @@ -139,6 +145,7 @@ describe("DocumentService", () => {
normalized_file_path: "documents/1/normalized.pdf",
file_type: "pdf",
file_size: pdfBytes.length,
content_hash: computeContentHash(pdfBytes),
metadata: {},
source: "api",
status: DocumentStatus.ongoing_ocr,
Expand Down Expand Up @@ -188,6 +195,7 @@ describe("DocumentService", () => {
normalized_file_path: "documents/1/normalized.pdf",
file_type: "pdf",
file_size: pdfBytes.length,
content_hash: computeContentHash(pdfBytes),
metadata: {},
source: "api",
status: DocumentStatus.ongoing_ocr,
Expand Down Expand Up @@ -328,6 +336,7 @@ describe("DocumentService", () => {
normalized_file_path: null as string | null,
file_type: "pdf",
file_size: 512,
content_hash: "abc123",
metadata: { source: "ground-truth-generation" },
source: "ground-truth-generation",
status: DocumentStatus.pre_ocr,
Expand Down Expand Up @@ -368,6 +377,7 @@ describe("DocumentService", () => {
normalized_file_path: null as string | null,
file_type: "pdf",
file_size: 256,
content_hash: "def456",
metadata: {},
source: "api",
status: DocumentStatus.pre_ocr,
Expand Down
Loading
Loading