diff --git a/app/api/__init__.py b/app/api/__init__.py
index 7c314902..439da84f 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -19,6 +19,7 @@ from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
+from app.api.similarity import router as similarity_router
from app.api.url_upload import router as url_upload_router
# Import all the individual routers
@@ -46,3 +47,4 @@ router.include_router(url_upload_router)
router.include_router(search_router)
router.include_router(queue_router)
router.include_router(saved_searches_router)
+router.include_router(similarity_router)
diff --git a/app/api/similarity.py b/app/api/similarity.py
new file mode 100644
index 00000000..9c14ce1b
--- /dev/null
+++ b/app/api/similarity.py
@@ -0,0 +1,97 @@
+"""Document similarity API endpoints.
+
+Provides an endpoint to find documents similar to a given file based on
+text embeddings and cosine similarity scoring.
+"""
+
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
+from sqlalchemy.orm import Session
+
+from app.auth import require_login
+from app.database import get_db
+from app.models import FileRecord
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+DbSession = Annotated[Session, Depends(get_db)]
+
+
+@router.get("/files/{file_id}/similar")
+@require_login
+def get_similar_documents(
+ request: Request,
+ file_id: int,
+ db: DbSession,
+ limit: int = Query(5, ge=1, le=20, description="Maximum number of similar documents to return"),
+ threshold: float = Query(0.3, ge=0.0, le=1.0, description="Minimum similarity score (0–1)"),
+):
+ """Find documents similar to the specified file.
+
+ Uses text embeddings generated from OCR-extracted text and cosine
+ similarity to rank documents by relevance. Similarity scores range
+ from 0 (completely different) to 1 (identical content).
+
+ Embeddings are generated on first access and cached for subsequent
+ requests. Documents without OCR text are excluded.
+
+ Query Parameters:
+ - limit: Maximum results to return (default: 5, max: 20)
+ - threshold: Minimum similarity score to include (default: 0.3)
+
+ Example:
+ ```
+ GET /api/files/42/similar?limit=5&threshold=0.5
+ ```
+
+ Response:
+ ```json
+ {
+ "file_id": 42,
+ "similar_documents": [
+ {
+ "file_id": 15,
+ "original_filename": "Invoice_2026-01.pdf",
+ "document_title": "January Invoice",
+ "similarity_score": 0.8934,
+ "mime_type": "application/pdf",
+ "created_at": "2026-01-15T10:30:00+00:00"
+ }
+ ],
+ "count": 1
+ }
+ ```
+ """
+ # Verify the file exists
+ file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ if not file_record:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
+
+ if not file_record.ocr_text or not file_record.ocr_text.strip():
+ return {
+ "file_id": file_id,
+ "similar_documents": [],
+ "count": 0,
+ "message": "No OCR text available for similarity comparison",
+ }
+
+ try:
+ from app.utils.similarity import find_similar_documents
+
+ similar = find_similar_documents(db, file_id, limit=limit, threshold=threshold)
+
+ return {
+ "file_id": file_id,
+ "similar_documents": similar,
+ "count": len(similar),
+ }
+ except Exception as e:
+ logger.error(f"Error finding similar documents for file {file_id}: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to compute document similarity",
+ )
diff --git a/app/models.py b/app/models.py
index 03974588..7aa3a473 100644
--- a/app/models.py
+++ b/app/models.py
@@ -67,6 +67,9 @@ class FileRecord(Base):
# Human-readable document title from AI metadata
document_title = Column(String, nullable=True)
+ # Pre-computed text embedding vector stored as JSON array of floats
+ embedding = Column(Text, nullable=True)
+
# Timestamp when we inserted this record
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
diff --git a/app/utils/similarity.py b/app/utils/similarity.py
new file mode 100644
index 00000000..617f1976
--- /dev/null
+++ b/app/utils/similarity.py
@@ -0,0 +1,197 @@
+"""Document similarity detection using text embeddings and cosine similarity.
+
+Provides functions to generate text embeddings via the configured AI provider
+(OpenAI-compatible) and compute cosine similarity scores between documents.
+Embeddings are cached in the ``FileRecord.embedding`` column to avoid
+redundant API calls.
+"""
+
+import json
+import logging
+import math
+from typing import Any
+
+from sqlalchemy.orm import Session
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+def _get_embedding_client() -> Any:
+ """Create an OpenAI client for embedding generation.
+
+ Returns:
+ An ``openai.OpenAI`` client instance configured from application settings.
+
+ Raises:
+ RuntimeError: If the ``openai`` package is not installed.
+ """
+ try:
+ import openai
+ except ImportError as exc:
+ raise RuntimeError("The 'openai' package is required for embedding generation") from exc
+
+ return openai.OpenAI(
+ api_key=settings.openai_api_key,
+ base_url=settings.openai_base_url,
+ )
+
+
+def generate_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
+ """Generate a text embedding vector using the OpenAI-compatible API.
+
+ Args:
+ text: The input text to embed. Truncated to ~8000 tokens worth of
+ characters to stay within model limits.
+ model: The embedding model to use. Defaults to ``text-embedding-3-small``.
+
+ Returns:
+ A list of floats representing the embedding vector.
+
+ Raises:
+ RuntimeError: If the OpenAI client cannot be created.
+ Exception: If the API call fails.
+ """
+ # Truncate very long texts to stay within token limits (~4 chars per token)
+ max_chars = 30000
+ if len(text) > max_chars:
+ text = text[:max_chars]
+
+ client = _get_embedding_client()
+ response = client.embeddings.create(input=text, model=model)
+ return response.data[0].embedding
+
+
+def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
+ """Compute cosine similarity between two vectors.
+
+ Args:
+ vec_a: First embedding vector.
+ vec_b: Second embedding vector.
+
+ Returns:
+ A similarity score between 0 and 1. Returns 0.0 if either vector
+ has zero magnitude.
+ """
+ if len(vec_a) != len(vec_b):
+ return 0.0
+
+ dot_product = sum(a * b for a, b in zip(vec_a, vec_b, strict=True))
+ magnitude_a = math.sqrt(sum(a * a for a in vec_a))
+ magnitude_b = math.sqrt(sum(b * b for b in vec_b))
+
+ if magnitude_a == 0.0 or magnitude_b == 0.0:
+ return 0.0
+
+ similarity = dot_product / (magnitude_a * magnitude_b)
+ # Clamp to [0, 1] to handle floating-point drift
+ return max(0.0, min(1.0, similarity))
+
+
+def _get_or_compute_embedding(db: Session, file_record: Any) -> list[float] | None:
+ """Retrieve a cached embedding or compute and store a new one.
+
+ Args:
+ db: Active database session.
+ file_record: A ``FileRecord`` instance.
+
+ Returns:
+ The embedding vector, or ``None`` if the document has no OCR text
+ or embedding generation fails.
+ """
+ # Return cached embedding if available
+ if file_record.embedding:
+ try:
+ return json.loads(file_record.embedding)
+ except (json.JSONDecodeError, TypeError):
+ logger.warning(f"Invalid cached embedding for file {file_record.id}, recomputing")
+
+ # Need OCR text to generate an embedding
+ if not file_record.ocr_text or not file_record.ocr_text.strip():
+ return None
+
+ try:
+ embedding = generate_embedding(file_record.ocr_text)
+ # Cache the embedding in the database
+ file_record.embedding = json.dumps(embedding)
+ db.commit()
+ return embedding
+ except Exception as e:
+ db.rollback()
+ logger.error(f"Failed to generate embedding for file {file_record.id}: {e}")
+ return None
+
+
+def find_similar_documents(
+ db: Session,
+ file_id: int,
+ limit: int = 5,
+ threshold: float = 0.3,
+) -> list[dict[str, Any]]:
+ """Find documents similar to the given file.
+
+ Computes cosine similarity between the target document's embedding and
+ all other documents that have OCR text. Results are sorted by descending
+ similarity score.
+
+ Args:
+ db: Active database session.
+ file_id: The ID of the target ``FileRecord``.
+ limit: Maximum number of similar documents to return.
+ threshold: Minimum similarity score (0–1) to include in results.
+
+ Returns:
+ A list of dicts, each containing:
+ - ``file_id``: The similar document's ID.
+ - ``original_filename``: The document's original filename.
+ - ``document_title``: The document's AI-extracted title (may be None).
+ - ``similarity_score``: Cosine similarity (0–1, rounded to 4 decimals).
+ - ``mime_type``: The document's MIME type.
+ - ``created_at``: ISO-formatted creation timestamp.
+ """
+ from app.models import FileRecord
+
+ # Get the target document
+ target = db.query(FileRecord).filter(FileRecord.id == file_id).first()
+ if not target:
+ return []
+
+ # Get the target embedding
+ target_embedding = _get_or_compute_embedding(db, target)
+ if not target_embedding:
+ return []
+
+ # Get candidate documents (those with OCR text, excluding the target)
+ candidates = (
+ db.query(FileRecord)
+ .filter(
+ FileRecord.id != file_id,
+ FileRecord.ocr_text.isnot(None),
+ FileRecord.ocr_text != "",
+ )
+ .all()
+ )
+
+ results = []
+ for candidate in candidates:
+ candidate_embedding = _get_or_compute_embedding(db, candidate)
+ if not candidate_embedding:
+ continue
+
+ score = cosine_similarity(target_embedding, candidate_embedding)
+ if score >= threshold:
+ results.append(
+ {
+ "file_id": candidate.id,
+ "original_filename": candidate.original_filename,
+ "document_title": candidate.document_title,
+ "similarity_score": round(score, 4),
+ "mime_type": candidate.mime_type,
+ "created_at": candidate.created_at.isoformat() if candidate.created_at else None,
+ }
+ )
+
+ # Sort by similarity score descending
+ results.sort(key=lambda x: x["similarity_score"], reverse=True)
+ return results[:limit]
diff --git a/frontend/templates/file_detail.html b/frontend/templates/file_detail.html
index 48029c20..8d0e99ff 100644
--- a/frontend/templates/file_detail.html
+++ b/frontend/templates/file_detail.html
@@ -951,7 +951,76 @@
{% if processed_file_exists %}
loadPDF('processed', fileId);
{% endif %}
+
+ // Load similar documents
+ loadSimilarDocuments(fileId);
});
+
+ // Similar documents loading
+ async function loadSimilarDocuments(fileId) {
+ const loadingDiv = document.getElementById('similar-documents-loading');
+ const contentDiv = document.getElementById('similar-documents-content');
+ const emptyDiv = document.getElementById('similar-documents-empty');
+ const errorDiv = document.getElementById('similar-documents-error');
+
+ try {
+ const response = await fetch(`/api/files/${fileId}/similar?limit=5&threshold=0.3`);
+ if (!response.ok) {
+ throw new Error(response.statusText || 'Request failed');
+ }
+
+ const data = await response.json();
+ loadingDiv.style.display = 'none';
+
+ if (!data.similar_documents || data.similar_documents.length === 0) {
+ emptyDiv.style.display = 'block';
+ if (data.message) {
+ emptyDiv.querySelector('p').textContent = data.message;
+ }
+ return;
+ }
+
+ // Build the results HTML
+ let html = '
';
+
+ contentDiv.innerHTML = html;
+ contentDiv.style.display = 'block';
+ } catch (error) {
+ console.error('Error loading similar documents:', error);
+ loadingDiv.style.display = 'none';
+ errorDiv.style.display = 'block';
+ document.getElementById('similar-documents-error-msg').textContent =
+ 'Failed to load similar documents: ' + error.message;
+ }
+ }
{% endif %}
{% endblock %}
@@ -1527,6 +1596,24 @@
{% endif %}
+
+
+
Similar Documents
+
+
+
Searching for similar documents…
+
+
+
+
+
No similar documents found.
+
+
+
+
Failed to load similar documents.
+
+
+
{% if original_file_exists or processed_file_exists %}
diff --git a/migrations/versions/009_add_embedding_column.py b/migrations/versions/009_add_embedding_column.py
new file mode 100644
index 00000000..5f833556
--- /dev/null
+++ b/migrations/versions/009_add_embedding_column.py
@@ -0,0 +1,27 @@
+"""Add embedding column to files table for document similarity
+
+Revision ID: 009_add_embedding_column
+Revises: 008_add_performance_indexes
+Create Date: 2026-03-01
+
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "009_add_embedding_column"
+down_revision: Union[str, None] = "008_add_performance_indexes"
+depends_on: Union[str, None] = None
+
+
+def upgrade() -> None:
+ """Add embedding column to files table for storing text embedding vectors."""
+ op.add_column("files", sa.Column("embedding", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ """Remove embedding column from files table."""
+ op.drop_column("files", "embedding")
diff --git a/tests/test_similarity.py b/tests/test_similarity.py
new file mode 100644
index 00000000..b81fd053
--- /dev/null
+++ b/tests/test_similarity.py
@@ -0,0 +1,445 @@
+"""Tests for document similarity detection.
+
+Tests the similarity utility functions and the API endpoint
+``GET /api/files/{file_id}/similar``.
+"""
+
+import json
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.models import FileRecord
+from app.utils.similarity import cosine_similarity, find_similar_documents
+
+# ---------------------------------------------------------------------------
+# Unit tests for cosine_similarity
+# ---------------------------------------------------------------------------
+
+
+class TestCosineSimilarity:
+ """Unit tests for the cosine_similarity function."""
+
+ @pytest.mark.unit
+ def test_identical_vectors_return_one(self):
+ """Identical vectors should have similarity of 1.0."""
+ vec = [1.0, 2.0, 3.0]
+ assert cosine_similarity(vec, vec) == pytest.approx(1.0)
+
+ @pytest.mark.unit
+ def test_orthogonal_vectors_return_zero(self):
+ """Orthogonal vectors should have similarity of 0.0."""
+ a = [1.0, 0.0]
+ b = [0.0, 1.0]
+ assert cosine_similarity(a, b) == pytest.approx(0.0)
+
+ @pytest.mark.unit
+ def test_opposite_vectors_clamped_to_zero(self):
+ """Opposite vectors would give negative cosine; clamp to 0."""
+ a = [1.0, 0.0]
+ b = [-1.0, 0.0]
+ assert cosine_similarity(a, b) == 0.0
+
+ @pytest.mark.unit
+ def test_different_length_vectors_return_zero(self):
+ """Vectors of different lengths should return 0.0."""
+ a = [1.0, 2.0, 3.0]
+ b = [1.0, 2.0]
+ assert cosine_similarity(a, b) == 0.0
+
+ @pytest.mark.unit
+ def test_zero_vector_returns_zero(self):
+ """Zero-magnitude vector should return 0.0."""
+ a = [0.0, 0.0, 0.0]
+ b = [1.0, 2.0, 3.0]
+ assert cosine_similarity(a, b) == 0.0
+
+ @pytest.mark.unit
+ def test_similar_vectors_high_score(self):
+ """Similar (but not identical) vectors should have a high score."""
+ a = [1.0, 2.0, 3.0]
+ b = [1.1, 2.1, 3.1]
+ score = cosine_similarity(a, b)
+ assert 0.99 < score <= 1.0
+
+ @pytest.mark.unit
+ def test_score_between_zero_and_one(self):
+ """All scores should be in [0, 1]."""
+ a = [1.0, 0.5, 0.0]
+ b = [0.0, 0.5, 1.0]
+ score = cosine_similarity(a, b)
+ assert 0.0 <= score <= 1.0
+
+ @pytest.mark.unit
+ def test_empty_vectors_return_zero(self):
+ """Empty vectors should return 0.0."""
+ assert cosine_similarity([], []) == 0.0
+
+
+# ---------------------------------------------------------------------------
+# Unit tests for find_similar_documents
+# ---------------------------------------------------------------------------
+
+
+class TestFindSimilarDocuments:
+ """Unit tests for the find_similar_documents function."""
+
+ @pytest.mark.unit
+ def test_returns_empty_for_missing_file(self, db_session):
+ """Should return empty list when file ID does not exist."""
+ result = find_similar_documents(db_session, file_id=9999)
+ assert result == []
+
+ @pytest.mark.unit
+ def test_returns_empty_when_no_ocr_text(self, db_session):
+ """Should return empty list when target file has no OCR text."""
+ file_record = FileRecord(
+ filehash="abc123",
+ local_filename="/tmp/test.pdf",
+ file_size=1024,
+ original_filename="test.pdf",
+ ocr_text=None,
+ )
+ db_session.add(file_record)
+ db_session.commit()
+
+ result = find_similar_documents(db_session, file_id=file_record.id)
+ assert result == []
+
+ @pytest.mark.unit
+ @patch("app.utils.similarity.generate_embedding")
+ def test_finds_similar_documents(self, mock_embed, db_session):
+ """Should find similar documents based on embedding similarity."""
+ # Create a target file with OCR text
+ target = FileRecord(
+ filehash="hash1",
+ local_filename="/tmp/target.pdf",
+ file_size=1024,
+ original_filename="target.pdf",
+ ocr_text="This is an invoice from Amazon for January 2026",
+ )
+ # Create a similar file
+ similar = FileRecord(
+ filehash="hash2",
+ local_filename="/tmp/similar.pdf",
+ file_size=2048,
+ original_filename="similar.pdf",
+ ocr_text="This is an invoice from Amazon for February 2026",
+ document_title="Amazon Invoice Feb",
+ mime_type="application/pdf",
+ )
+ # Create a different file
+ different = FileRecord(
+ filehash="hash3",
+ local_filename="/tmp/different.pdf",
+ file_size=512,
+ original_filename="different.pdf",
+ ocr_text="Recipe for chocolate cake with detailed instructions",
+ document_title="Chocolate Cake Recipe",
+ mime_type="application/pdf",
+ )
+
+ db_session.add_all([target, similar, different])
+ db_session.commit()
+
+ # Mock embeddings that reflect similarity
+ target_embedding = [1.0, 0.0, 0.0]
+ similar_embedding = [0.95, 0.05, 0.0]
+ different_embedding = [0.0, 0.0, 1.0]
+
+ def mock_embed_side_effect(text):
+ if "January" in text or "invoice" in text.lower()[:30]:
+ return target_embedding
+ elif "February" in text:
+ return similar_embedding
+ else:
+ return different_embedding
+
+ mock_embed.side_effect = mock_embed_side_effect
+
+ result = find_similar_documents(db_session, file_id=target.id, threshold=0.3)
+
+ assert len(result) == 1
+ assert result[0]["file_id"] == similar.id
+ assert result[0]["similarity_score"] > 0.9
+ assert result[0]["original_filename"] == "similar.pdf"
+
+ @pytest.mark.unit
+ @patch("app.utils.similarity.generate_embedding")
+ def test_respects_threshold(self, mock_embed, db_session):
+ """Should filter out documents below the threshold."""
+ target = FileRecord(
+ filehash="hash1",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="target.pdf",
+ ocr_text="target text",
+ )
+ candidate = FileRecord(
+ filehash="hash2",
+ local_filename="/tmp/c.pdf",
+ file_size=100,
+ original_filename="candidate.pdf",
+ ocr_text="different text",
+ )
+ db_session.add_all([target, candidate])
+ db_session.commit()
+
+ # Return nearly orthogonal vectors -> low similarity
+ mock_embed.side_effect = lambda text: [1.0, 0.0] if "target" in text else [0.1, 0.99]
+
+ result = find_similar_documents(db_session, file_id=target.id, threshold=0.9)
+ assert len(result) == 0
+
+ @pytest.mark.unit
+ @patch("app.utils.similarity.generate_embedding")
+ def test_respects_limit(self, mock_embed, db_session):
+ """Should respect the limit parameter."""
+ target = FileRecord(
+ filehash="hash0",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="target.pdf",
+ ocr_text="target text",
+ )
+ db_session.add(target)
+
+ for i in range(5):
+ f = FileRecord(
+ filehash=f"hash{i + 1}",
+ local_filename=f"/tmp/c{i}.pdf",
+ file_size=100,
+ original_filename=f"candidate_{i}.pdf",
+ ocr_text=f"similar text {i}",
+ )
+ db_session.add(f)
+ db_session.commit()
+
+ mock_embed.return_value = [1.0, 0.0, 0.0]
+
+ result = find_similar_documents(db_session, file_id=target.id, limit=2, threshold=0.0)
+ assert len(result) <= 2
+
+ @pytest.mark.unit
+ def test_uses_cached_embedding(self, db_session):
+ """Should use cached embeddings from the database."""
+ cached_embedding = [0.5, 0.5, 0.5]
+
+ target = FileRecord(
+ filehash="hash1",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="target.pdf",
+ ocr_text="some text",
+ embedding=json.dumps(cached_embedding),
+ )
+ candidate = FileRecord(
+ filehash="hash2",
+ local_filename="/tmp/c.pdf",
+ file_size=100,
+ original_filename="candidate.pdf",
+ ocr_text="some text too",
+ embedding=json.dumps(cached_embedding),
+ )
+ db_session.add_all([target, candidate])
+ db_session.commit()
+
+ # No mock needed — cached embeddings should be used
+ result = find_similar_documents(db_session, file_id=target.id, threshold=0.0)
+ assert len(result) == 1
+ assert result[0]["similarity_score"] == pytest.approx(1.0)
+
+
+# ---------------------------------------------------------------------------
+# Integration tests for the API endpoint
+# ---------------------------------------------------------------------------
+
+
+class TestSimilarDocumentsAPI:
+ """Integration tests for GET /api/files/{file_id}/similar."""
+
+ @pytest.mark.integration
+ def test_file_not_found(self, client: TestClient):
+ """Should return 404 for non-existent file."""
+ response = client.get("/api/files/9999/similar")
+ assert response.status_code == 404
+
+ @pytest.mark.integration
+ def test_no_ocr_text(self, client: TestClient, db_session):
+ """Should return empty results when file has no OCR text."""
+ file_record = FileRecord(
+ filehash="abc123",
+ local_filename="/tmp/test.pdf",
+ file_size=1024,
+ original_filename="test.pdf",
+ ocr_text=None,
+ )
+ db_session.add(file_record)
+ db_session.commit()
+
+ response = client.get(f"/api/files/{file_record.id}/similar")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["count"] == 0
+ assert data["similar_documents"] == []
+ assert "message" in data
+
+ @pytest.mark.integration
+ @patch("app.utils.similarity.generate_embedding")
+ def test_returns_similar_documents(self, mock_embed, client: TestClient, db_session):
+ """Should return similar documents with scores."""
+ target = FileRecord(
+ filehash="hash1",
+ local_filename="/tmp/target.pdf",
+ file_size=1024,
+ original_filename="target.pdf",
+ ocr_text="Invoice from Amazon January 2026",
+ )
+ similar = FileRecord(
+ filehash="hash2",
+ local_filename="/tmp/similar.pdf",
+ file_size=2048,
+ original_filename="similar_invoice.pdf",
+ ocr_text="Invoice from Amazon February 2026",
+ document_title="Amazon Invoice",
+ mime_type="application/pdf",
+ )
+ db_session.add_all([target, similar])
+ db_session.commit()
+
+ mock_embed.return_value = [1.0, 0.0, 0.0]
+
+ response = client.get(f"/api/files/{target.id}/similar")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["file_id"] == target.id
+ assert data["count"] >= 1
+ assert len(data["similar_documents"]) >= 1
+
+ doc = data["similar_documents"][0]
+ assert "file_id" in doc
+ assert "similarity_score" in doc
+ assert 0 <= doc["similarity_score"] <= 1
+ assert "original_filename" in doc
+
+ @pytest.mark.integration
+ @patch("app.utils.similarity.generate_embedding")
+ def test_query_parameters(self, mock_embed, client: TestClient, db_session):
+ """Should respect limit and threshold query parameters."""
+ target = FileRecord(
+ filehash="hash1",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="t.pdf",
+ ocr_text="test",
+ )
+ db_session.add(target)
+
+ for i in range(5):
+ f = FileRecord(
+ filehash=f"h{i}",
+ local_filename=f"/tmp/c{i}.pdf",
+ file_size=100,
+ original_filename=f"c{i}.pdf",
+ ocr_text=f"text {i}",
+ )
+ db_session.add(f)
+ db_session.commit()
+
+ mock_embed.return_value = [1.0, 0.0]
+
+ response = client.get(f"/api/files/{target.id}/similar?limit=2&threshold=0.0")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["count"] <= 2
+
+ @pytest.mark.integration
+ def test_invalid_limit(self, client: TestClient, db_session):
+ """Should reject invalid limit values."""
+ file_record = FileRecord(
+ filehash="abc",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="t.pdf",
+ )
+ db_session.add(file_record)
+ db_session.commit()
+
+ response = client.get(f"/api/files/{file_record.id}/similar?limit=0")
+ assert response.status_code == 422
+
+ @pytest.mark.integration
+ def test_invalid_threshold(self, client: TestClient, db_session):
+ """Should reject threshold values outside [0, 1]."""
+ file_record = FileRecord(
+ filehash="abc",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="t.pdf",
+ )
+ db_session.add(file_record)
+ db_session.commit()
+
+ response = client.get(f"/api/files/{file_record.id}/similar?threshold=1.5")
+ assert response.status_code == 422
+
+ @pytest.mark.integration
+ def test_empty_ocr_text(self, client: TestClient, db_session):
+ """Should return empty results when OCR text is empty string."""
+ file_record = FileRecord(
+ filehash="abc",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="t.pdf",
+ ocr_text="",
+ )
+ db_session.add(file_record)
+ db_session.commit()
+
+ response = client.get(f"/api/files/{file_record.id}/similar")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["count"] == 0
+
+ @pytest.mark.integration
+ @patch("app.utils.similarity.generate_embedding")
+ def test_response_structure(self, mock_embed, client: TestClient, db_session):
+ """Should return proper response structure for each similar document."""
+ target = FileRecord(
+ filehash="h1",
+ local_filename="/tmp/t.pdf",
+ file_size=100,
+ original_filename="target.pdf",
+ ocr_text="Some text content here",
+ )
+ other = FileRecord(
+ filehash="h2",
+ local_filename="/tmp/o.pdf",
+ file_size=200,
+ original_filename="other.pdf",
+ ocr_text="Some similar text content",
+ document_title="Other Doc",
+ mime_type="application/pdf",
+ )
+ db_session.add_all([target, other])
+ db_session.commit()
+
+ mock_embed.return_value = [1.0, 0.0]
+
+ response = client.get(f"/api/files/{target.id}/similar")
+ assert response.status_code == 200
+ data = response.json()
+
+ assert "file_id" in data
+ assert "similar_documents" in data
+ assert "count" in data
+
+ if data["count"] > 0:
+ doc = data["similar_documents"][0]
+ assert "file_id" in doc
+ assert "original_filename" in doc
+ assert "document_title" in doc
+ assert "similarity_score" in doc
+ assert "mime_type" in doc
+ assert "created_at" in doc