Files
gh-christianlouis-docuelevate/app/api/similarity.py
T
copilot-swe-agent[bot] c724b8d83a feat(similarity): add similarity pairs dashboard, step tracking, and fix tests for pre-computed embeddings
- Add GET /api/similarity/pairs endpoint for corpus-wide pair discovery
- Add /similarity view route and similarity_dashboard.html template
- Add Similarity link to desktop and mobile nav menus
- Register compute_embedding as a tracked FileProcessingStep
- Update compute_embedding task with update_step_status calls
- Add compute_embedding to flow visualization in _compute_processing_flow
- Add backfill_missing_embeddings periodic beat task (every 5 min)
- Return clear message when embedding not yet computed in similar docs API
- Fix all tests to use pre-computed embeddings (no lazy API calls)
- Add tests for similarity pairs, backfill task, and embedding-not-computed

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-02 13:12:59 +00:00

464 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Document similarity API endpoints.
Provides endpoints to find documents similar to a given file based on
text embeddings and cosine similarity scoring, plus debug/diagnostic
endpoints for inspecting and triggering embedding computation.
"""
import json
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.config import settings
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 (01)"),
):
"""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",
}
# Check whether an embedding has been computed yet
if not file_record.embedding:
return {
"file_id": file_id,
"similar_documents": [],
"count": 0,
"message": (
"Embedding not yet computed for this file. "
"It will be generated automatically during processing or via the backfill task. "
"You can also trigger it manually with POST /api/files/{file_id}/compute-embedding."
),
}
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",
)
# ---------------------------------------------------------------------------
# Debug / diagnostic endpoints
# ---------------------------------------------------------------------------
@router.get("/files/{file_id}/embedding-status")
@require_login
def get_embedding_status(
request: Request,
file_id: int,
db: DbSession,
):
"""Return the embedding status for a single file.
Useful for debugging whether the embedding has been computed
and cached for a given document.
Response:
```json
{
"file_id": 42,
"has_embedding": true,
"embedding_dimensions": 1536,
"has_ocr_text": true,
"ocr_text_length": 4200,
"embedding_model": "text-embedding-3-small"
}
```
"""
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")
has_embedding = False
embedding_dimensions = None
if file_record.embedding:
try:
parsed = json.loads(file_record.embedding)
has_embedding = True
embedding_dimensions = len(parsed)
except (json.JSONDecodeError, TypeError):
pass
has_ocr_text = bool(file_record.ocr_text and file_record.ocr_text.strip())
return {
"file_id": file_id,
"has_embedding": has_embedding,
"embedding_dimensions": embedding_dimensions,
"has_ocr_text": has_ocr_text,
"ocr_text_length": len(file_record.ocr_text) if file_record.ocr_text else 0,
"embedding_model": settings.embedding_model,
}
@router.post("/files/{file_id}/compute-embedding")
@require_login
def trigger_compute_embedding(
request: Request,
file_id: int,
db: DbSession,
):
"""Trigger embedding computation for a single file.
If the file already has a cached embedding it will be recomputed.
The computation happens synchronously so the caller receives the
result immediately.
Response:
```json
{
"file_id": 42,
"status": "success",
"embedding_dimensions": 1536
}
```
"""
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():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File has no OCR text — cannot generate embedding",
)
try:
from app.utils.similarity import generate_embedding
# Clear cached embedding to force recomputation
file_record.embedding = None
db.flush()
embedding = generate_embedding(file_record.ocr_text)
file_record.embedding = json.dumps(embedding)
db.commit()
return {
"file_id": file_id,
"status": "success",
"embedding_dimensions": len(embedding),
}
except Exception as e:
db.rollback()
logger.error(f"Failed to compute embedding for file {file_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Embedding computation failed: {e}",
)
@router.get("/diagnostic/embeddings")
@require_login
def get_embeddings_overview(
request: Request,
db: DbSession,
):
"""Return an overview of embedding status across all files.
Provides aggregate counts as well as a per-file breakdown so an
administrator can quickly identify documents that are missing
embeddings.
Response:
```json
{
"total_files": 120,
"files_with_ocr_text": 95,
"files_with_embedding": 42,
"files_missing_embedding": 53,
"embedding_model": "text-embedding-3-small",
"files": [
{
"file_id": 1,
"original_filename": "invoice.pdf",
"has_ocr_text": true,
"has_embedding": true,
"embedding_dimensions": 1536
}
]
}
```
"""
all_files = db.query(FileRecord).order_by(FileRecord.id.desc()).all()
files_info = []
total_with_ocr = 0
total_with_embedding = 0
for f in all_files:
has_ocr = bool(f.ocr_text and f.ocr_text.strip())
has_emb = False
emb_dims = None
if f.embedding:
try:
parsed = json.loads(f.embedding)
has_emb = True
emb_dims = len(parsed)
except (json.JSONDecodeError, TypeError):
pass
if has_ocr:
total_with_ocr += 1
if has_emb:
total_with_embedding += 1
files_info.append(
{
"file_id": f.id,
"original_filename": f.original_filename,
"has_ocr_text": has_ocr,
"has_embedding": has_emb,
"embedding_dimensions": emb_dims,
}
)
return {
"total_files": len(all_files),
"files_with_ocr_text": total_with_ocr,
"files_with_embedding": total_with_embedding,
"files_missing_embedding": total_with_ocr - total_with_embedding,
"embedding_model": settings.embedding_model,
"files": files_info,
}
@router.post("/diagnostic/compute-all-embeddings")
@require_login
def trigger_compute_all_embeddings(
request: Request,
db: DbSession,
):
"""Queue embedding computation for all files that have OCR text but no embedding.
Each file is processed as a separate Celery task so the endpoint
returns immediately.
Response:
```json
{
"status": "queued",
"files_queued": 53
}
```
"""
candidates = (
db.query(FileRecord)
.filter(
FileRecord.ocr_text.isnot(None),
FileRecord.ocr_text != "",
(FileRecord.embedding.is_(None)) | (FileRecord.embedding == ""),
)
.all()
)
queued = 0
for f in candidates:
try:
from app.tasks.compute_embedding import compute_document_embedding
compute_document_embedding.delay(f.id)
queued += 1
except Exception as e:
logger.warning(f"Could not queue embedding for file {f.id}: {e}")
return {
"status": "queued",
"files_queued": queued,
}
@router.get("/similarity/pairs")
@require_login
def get_similarity_pairs(
request: Request,
db: DbSession,
threshold: float = Query(0.7, ge=0.0, le=1.0, description="Minimum similarity score for a pair"),
limit: int = Query(50, ge=1, le=200, description="Maximum number of pairs to return"),
page: int = Query(1, ge=1, description="Page number"),
):
"""Return pairs of documents with high similarity across the entire corpus.
Unlike the per-file ``/files/{id}/similar`` endpoint, this scans every
document that has a pre-computed embedding and returns **all** pairs
whose cosine similarity exceeds ``threshold``, sorted by descending
score.
To keep memory bounded the query loads only the columns needed for
scoring and streams results in chunks.
Response:
```json
{
"pairs": [
{
"file_a": {"file_id": 1, "original_filename": "invoice_jan.pdf", ...},
"file_b": {"file_id": 5, "original_filename": "invoice_feb.pdf", ...},
"similarity_score": 0.94
}
],
"total_pairs": 12,
"threshold": 0.7,
"page": 1,
"pages": 1,
"embedding_coverage": {"total_files": 120, "files_with_embedding": 95}
}
```
"""
from app.utils.similarity import cosine_similarity
# Load all files that have embeddings (columns only for efficiency)
rows = (
db.query(
FileRecord.id,
FileRecord.original_filename,
FileRecord.document_title,
FileRecord.mime_type,
FileRecord.created_at,
FileRecord.embedding,
)
.filter(
FileRecord.embedding.isnot(None),
FileRecord.embedding != "",
)
.order_by(FileRecord.id)
.all()
)
# Parse embeddings upfront
parsed: list[tuple] = []
for row in rows:
try:
vec = json.loads(row.embedding)
parsed.append((row, vec))
except (json.JSONDecodeError, TypeError):
continue
# Pairwise comparison (triangle: i < j avoids duplicating A↔B / B↔A)
all_pairs: list[dict] = []
for i in range(len(parsed)):
row_a, vec_a = parsed[i]
for j in range(i + 1, len(parsed)):
row_b, vec_b = parsed[j]
score = cosine_similarity(vec_a, vec_b)
if score >= threshold:
all_pairs.append(
{
"file_a": _row_to_dict(row_a),
"file_b": _row_to_dict(row_b),
"similarity_score": round(score, 4),
}
)
# Sort by score descending
all_pairs.sort(key=lambda p: p["similarity_score"], reverse=True)
total_pairs = len(all_pairs)
total_pages = max(1, (total_pairs + limit - 1) // limit)
offset = (page - 1) * limit
page_pairs = all_pairs[offset : offset + limit]
total_files = db.query(FileRecord).count()
return {
"pairs": page_pairs,
"total_pairs": total_pairs,
"threshold": threshold,
"page": page,
"pages": total_pages,
"per_page": limit,
"embedding_coverage": {
"total_files": total_files,
"files_with_embedding": len(parsed),
},
}
def _row_to_dict(row) -> dict:
"""Serialise a column-only query row to a dict for JSON responses."""
return {
"file_id": row.id,
"original_filename": row.original_filename,
"document_title": row.document_title,
"mime_type": row.mime_type,
"created_at": row.created_at.isoformat() if row.created_at else None,
}