feat(deduplication): implement configurable duplicate file detection

- Add enable_deduplication and show_deduplication_step config options
- Rename hash_file step to check_for_duplicates
- Make deduplication step conditional based on configuration
- Add is_duplicate and duplicate_of_id fields to FileRecord model
- Create database migration for new deduplication fields
- Update process_document task to log deduplication results
- Update step visualization to show/hide step based on config
- Update status calculations to include deduplication step conditionally
- Default: deduplication enabled, step displayed
- Can be configured to hide from UI while still processing
This commit is contained in:
Christian Krakau-Louis
2026-02-12 01:13:37 +01:00
parent a4f3d92d20
commit 4c11cfd6dd
8 changed files with 173 additions and 22 deletions
+10
View File
@@ -179,6 +179,16 @@ class Settings(BaseSettings):
description="Maximum size for a single file chunk in bytes. If set and file exceeds this, it will be split into smaller chunks for processing. Default: None (no splitting).", description="Maximum size for a single file chunk in bytes. If set and file exceeds this, it will be split into smaller chunks for processing. Default: None (no splitting).",
) )
# Deduplication settings - prevents processing of duplicate files
enable_deduplication: bool = Field(
default=True,
description="Enable deduplication check before processing. If enabled, files with the same SHA-256 hash as previously processed files will not be processed again. Default: True (enabled).",
)
show_deduplication_step: bool = Field(
default=True,
description="Show the 'Check for Duplicates' step in processing history. If False, the check is still performed but not displayed. Default: True.",
)
# Security Headers Configuration (see SECURITY_AUDIT.md and docs/DeploymentGuide.md) # Security Headers Configuration (see SECURITY_AUDIT.md and docs/DeploymentGuide.md)
# Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.) # Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.)
# that already adds these headers. Enable if deploying directly without a reverse proxy. # that already adds these headers. Enable if deploying directly without a reverse proxy.
+8 -1
View File
@@ -1,6 +1,6 @@
# app/models.py # app/models.py
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func, Boolean
from app.database import Base from app.database import Base
@@ -44,6 +44,13 @@ class FileRecord(Base):
# MIME type or extension (optional) # MIME type or extension (optional)
mime_type = Column(String) mime_type = Column(String)
# Deduplication tracking: True if this file is a duplicate of another file
# When a duplicate is detected, this file record is created but marked as duplicate
is_duplicate = Column(Boolean, default=False, nullable=False, index=True)
# If this is a duplicate, record the ID of the original file for reference
duplicate_of_id = Column(Integer, ForeignKey("files.id"), nullable=True)
# Timestamp when we inserted this record # Timestamp when we inserted this record
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+61 -15
View File
@@ -62,10 +62,15 @@ def process_document(self, original_local_file: str, original_filename: str = No
) )
return {"error": "File not found"} return {"error": "File not found"}
# 0. Compute the file hash and check for duplicates # 0. Check for duplicate files (if enabled)
logger.info(f"[{task_id}] Computing file hash...") if settings.enable_deduplication:
log_task_progress(task_id, "hash_file", "in_progress", "Computing file hash") logger.info(f"[{task_id}] Computing file hash for deduplication check...")
filehash = hash_file(original_local_file) log_task_progress(task_id, "check_for_duplicates", "in_progress", "Computing file hash for deduplication")
filehash = hash_file(original_local_file)
else:
logger.info(f"[{task_id}] Computing file hash (deduplication disabled)...")
filehash = hash_file(original_local_file)
# Use provided original_filename or fall back to basename of path # Use provided original_filename or fall back to basename of path
if original_filename is None: if original_filename is None:
original_filename = os.path.basename(original_local_file) original_filename = os.path.basename(original_local_file)
@@ -75,12 +80,15 @@ def process_document(self, original_local_file: str, original_filename: str = No
mime_type = "application/octet-stream" mime_type = "application/octet-stream"
logger.info(f"[{task_id}] File hash: {filehash[:10]}..., Size: {file_size} bytes, MIME: {mime_type}") logger.info(f"[{task_id}] File hash: {filehash[:10]}..., Size: {file_size} bytes, MIME: {mime_type}")
log_task_progress(
task_id, # Log deduplication step result (only if enabled)
"hash_file", if settings.enable_deduplication:
"success", log_task_progress(
f"Hash: {filehash[:10]}..., Size: {file_size} bytes", task_id,
) "check_for_duplicates",
"in_progress",
f"Hash: {filehash[:10]}..., checking for duplicates",
)
# Acquire DB session in the task # Acquire DB session in the task
with SessionLocal() as db: with SessionLocal() as db:
@@ -103,29 +111,66 @@ def process_document(self, original_local_file: str, original_filename: str = No
new_record = existing_record new_record = existing_record
else: else:
existing = db.query(FileRecord).filter_by(filehash=filehash).one_or_none() existing = db.query(FileRecord).filter_by(filehash=filehash).one_or_none()
if existing: if existing and settings.enable_deduplication:
logger.info(f"[{task_id}] Duplicate file detected (hash={filehash[:10]}...) Skipping processing.") logger.info(f"[{task_id}] Duplicate file detected (hash={filehash[:10]}...) Skipping processing.")
# Create a file record for this duplicate with is_duplicate=True
duplicate_record = FileRecord(
filehash=filehash,
original_filename=original_filename,
local_filename="",
file_size=file_size,
mime_type=mime_type,
is_duplicate=True,
duplicate_of_id=existing.id,
)
db.add(duplicate_record)
db.commit()
db.refresh(duplicate_record)
if settings.enable_deduplication and settings.show_deduplication_step:
log_task_progress(
task_id,
"check_for_duplicates",
"success",
f"Duplicate detected - matching file ID {existing.id}",
file_id=duplicate_record.id,
detail=(
f"Duplicate file detected.\n"
f"File hash: {filehash}\n"
f"Original file record ID: {existing.id}\n"
f"This file record ID: {duplicate_record.id}\n"
f"Original filename: {original_filename}"
),
)
log_task_progress( log_task_progress(
task_id, task_id,
"process_document", "process_document",
"success", "success",
"Duplicate file detected, skipping", "Duplicate file detected, skipping",
file_id=existing.id, file_id=duplicate_record.id,
detail=( detail=(
f"Duplicate file detected.\n" f"Duplicate file detected.\n"
f"File hash: {filehash}\n" f"File hash: {filehash}\n"
f"Existing file record ID: {existing.id}\n" f"Original file record ID: {existing.id}\n"
f"Original filename: {original_filename}" f"Original filename: {original_filename}"
), ),
) )
return { return {
"status": "duplicate_file", "status": "duplicate_file",
"file_id": existing.id, "file_id": duplicate_record.id,
"original_file_id": existing.id,
"detail": "File already processed.", "detail": "File already processed.",
} }
# Not a duplicate -> insert a new record # Not a duplicate (or deduplication disabled) -> insert a new record
logger.info(f"[{task_id}] Creating new file record in database") logger.info(f"[{task_id}] Creating new file record in database")
if settings.enable_deduplication and settings.show_deduplication_step:
log_task_progress(
task_id,
"check_for_duplicates",
"success",
"New file - no duplicates found",
)
log_task_progress(task_id, "create_file_record", "in_progress", "Creating file record") log_task_progress(task_id, "create_file_record", "in_progress", "Creating file record")
new_record = FileRecord( new_record = FileRecord(
filehash=filehash, filehash=filehash,
@@ -133,6 +178,7 @@ def process_document(self, original_local_file: str, original_filename: str = No
local_filename="", # Will fill in after we move it local_filename="", # Will fill in after we move it
file_size=file_size, file_size=file_size,
mime_type=mime_type, mime_type=mime_type,
is_duplicate=False,
) )
db.add(new_record) db.add(new_record)
db.commit() db.commit()
+6
View File
@@ -52,6 +52,8 @@ def apply_status_filter(query: Query, db: Session, status: Optional[str]) -> Que
# Define which steps are "real" status-determining steps # Define which steps are "real" status-determining steps
# Only high-level logical steps and actual upload destinations (not queue_* steps) # Only high-level logical steps and actual upload destinations (not queue_* steps)
from app.config import settings
REAL_STEPS = { REAL_STEPS = {
"create_file_record", "create_file_record",
"check_text", "check_text",
@@ -74,6 +76,10 @@ def apply_status_filter(query: Query, db: Session, status: Optional[str]) -> Que
"upload_to_s3", "upload_to_s3",
} }
# Add check_for_duplicates if deduplication is enabled
if settings.enable_deduplication:
REAL_STEPS.add("check_for_duplicates")
# Filter to only real steps # Filter to only real steps
real_steps_subq = ( real_steps_subq = (
db.query(FileProcessingStep) db.query(FileProcessingStep)
+6
View File
@@ -62,6 +62,8 @@ def get_files_processing_status(db: Session, file_ids: List[int]) -> Dict[int, D
dict mapping file_id to status dict dict mapping file_id to status dict
""" """
# Define which steps are "real" status-determining steps # Define which steps are "real" status-determining steps
from app.config import settings
REAL_STEPS = { REAL_STEPS = {
"create_file_record", "create_file_record",
"check_text", "check_text",
@@ -84,6 +86,10 @@ def get_files_processing_status(db: Session, file_ids: List[int]) -> Dict[int, D
"upload_to_s3", "upload_to_s3",
} }
# Add check_for_duplicates if deduplication is enabled
if settings.enable_deduplication:
REAL_STEPS.add("check_for_duplicates")
# Get all REAL steps for these files in one query # Get all REAL steps for these files in one query
steps = ( steps = (
db.query(FileProcessingStep) db.query(FileProcessingStep)
+21 -2
View File
@@ -10,11 +10,12 @@ from typing import Dict, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models import FileProcessingStep from app.models import FileProcessingStep
# Define the expected processing steps for a standard file workflow # Define the expected processing steps for a standard file workflow
MAIN_PROCESSING_STEPS = [ # The "check_for_duplicates" step is conditionally included based on enable_deduplication setting
"hash_file", BASE_MAIN_PROCESSING_STEPS = [
"create_file_record", "create_file_record",
"check_text", "check_text",
"extract_text", # Or "process_with_azure_document_intelligence" "extract_text", # Or "process_with_azure_document_intelligence"
@@ -24,6 +25,16 @@ MAIN_PROCESSING_STEPS = [
"send_to_all_destinations", "send_to_all_destinations",
] ]
OPTIONAL_PROCESSING_STEPS = {
"check_for_duplicates": settings.enable_deduplication, # Only if deduplication is enabled
}
# Combine steps based on configuration
MAIN_PROCESSING_STEPS = []
if settings.enable_deduplication:
MAIN_PROCESSING_STEPS.append("check_for_duplicates")
MAIN_PROCESSING_STEPS.extend(BASE_MAIN_PROCESSING_STEPS)
def initialize_file_steps(db: Session, file_id: int, include_uploads: bool = False) -> None: def initialize_file_steps(db: Session, file_id: int, include_uploads: bool = False) -> None:
""" """
@@ -188,6 +199,10 @@ def get_file_overall_status(db: Session, file_id: int) -> Dict:
"send_to_all_destinations", "send_to_all_destinations",
} }
# Add check_for_duplicates if deduplication is enabled
if settings.enable_deduplication:
REAL_MAIN_STEPS.add("check_for_duplicates")
all_steps = db.query(FileProcessingStep).filter(FileProcessingStep.file_id == file_id).all() all_steps = db.query(FileProcessingStep).filter(FileProcessingStep.file_id == file_id).all()
# Filter to only real steps # Filter to only real steps
@@ -270,6 +285,10 @@ def get_step_summary(db: Session, file_id: int) -> Dict:
"send_to_all_destinations", "send_to_all_destinations",
} }
# Add check_for_duplicates if deduplication is enabled
if settings.enable_deduplication:
REAL_MAIN_STEPS.add("check_for_duplicates")
steps = db.query(FileProcessingStep).filter(FileProcessingStep.file_id == file_id).all() steps = db.query(FileProcessingStep).filter(FileProcessingStep.file_id == file_id).all()
main_counts = {"queued": 0, "in_progress": 0, "success": 0, "failure": 0, "skipped": 0} main_counts = {"queued": 0, "in_progress": 0, "success": 0, "failure": 0, "skipped": 0}
+16 -4
View File
@@ -217,7 +217,7 @@ def _compute_processing_flow(logs):
""" """
# Define the main processing stages # Define the main processing stages
stages = { stages = {
"hash_file": {"label": "File Upload & Hash", "next": ["create_file_record"]}, "check_for_duplicates": {"label": "Check for Duplicates", "next": ["create_file_record"]},
"create_file_record": {"label": "Create File Record", "next": ["check_text"]}, "create_file_record": {"label": "Create File Record", "next": ["check_text"]},
"check_text": { "check_text": {
"label": "Check Embedded Text", "label": "Check Embedded Text",
@@ -234,6 +234,14 @@ def _compute_processing_flow(logs):
"send_to_all_destinations": {"label": "Upload to Destinations", "next": [], "has_branches": True}, "send_to_all_destinations": {"label": "Upload to Destinations", "next": [], "has_branches": True},
} }
# Filter out deduplication step if not enabled or if not showing it
from app.config import settings
if not settings.enable_deduplication or not settings.show_deduplication_step:
stages.pop("check_for_duplicates", None)
# Update the next pointer for create_file_record
if "create_file_record" in stages:
stages["create_file_record"]["next"] = ["check_text"]
# Define upload sub-tasks (branches) # Define upload sub-tasks (branches)
upload_tasks = { upload_tasks = {
"upload_to_dropbox": "Dropbox", "upload_to_dropbox": "Dropbox",
@@ -345,9 +353,13 @@ def _compute_step_summary(logs):
Note: This function is order-independent - it selects the latest status per step Note: This function is order-independent - it selects the latest status per step
based on timestamp, regardless of input log ordering. based on timestamp, regardless of input log ordering.
""" """
from app.config import settings
# Count statuses for main processing steps (not uploads) # Count statuses for main processing steps (not uploads)
main_steps = [ main_steps = []
"hash_file", if settings.enable_deduplication and settings.show_deduplication_step:
main_steps.append("check_for_duplicates")
main_steps.extend([
"create_file_record", "create_file_record",
"check_text", "check_text",
"extract_text", "extract_text",
@@ -356,7 +368,7 @@ def _compute_step_summary(logs):
"embed_metadata_into_pdf", "embed_metadata_into_pdf",
"finalize_document_storage", "finalize_document_storage",
"send_to_all_destinations", "send_to_all_destinations",
] ])
upload_prefixes = ["upload_to_", "queue_"] upload_prefixes = ["upload_to_", "queue_"]
@@ -0,0 +1,45 @@
"""Add deduplication support with is_duplicate and duplicate_of_id fields
Revision ID: 003_add_deduplication_support
Revises: 002_add_file_paths
Create Date: 2026-02-12
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "003_add_deduplication_support"
down_revision: Union[str, None] = "002_add_file_paths"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add is_duplicate and duplicate_of_id columns to files table."""
# Add is_duplicate column with default False
op.add_column("files", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="0"))
# Add duplicate_of_id column as foreign key to self
op.add_column("files", sa.Column("duplicate_of_id", sa.Integer(), nullable=True))
# Create index on is_duplicate for efficient filtering
op.create_index("ix_files_is_duplicate", "files", ["is_duplicate"])
# Create foreign key relationship
op.create_foreign_key("fk_files_duplicate_of_id", "files", "files", ["duplicate_of_id"], ["id"])
def downgrade() -> None:
"""Remove is_duplicate and duplicate_of_id columns from files table."""
# Drop foreign key
op.drop_constraint("fk_files_duplicate_of_id", "files", type_="foreignkey")
# Drop index
op.drop_index("ix_files_is_duplicate", table_name="files")
# Drop columns
op.drop_column("files", "duplicate_of_id")
op.drop_column("files", "is_duplicate")