pdf-quiz-generator/backend/app/services/embedding_service.py
Daniel 47ba213ae3 Major platform update: pgvector search, multi-provider TTS, settings page, CLI
Features:
- Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE)
- AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim)
- Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS
- Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts)
- Good morning/afternoon greeting on dashboard
- manage.py CLI: reset-password, list-users, reembed
- Email verification enforced: register no longer returns JWT for unverified users
- Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets
- TTS button: loading/playing states, voice selector locked during playback
- TTS auto-stops when navigating between questions
- Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed
- OpenAI Alloy as default TTS voice; favicon added
- SMTP configured via smtp2go; password reset rate limiting (3/hour)
- PostgreSQL upgraded to pgvector/pgvector:pg16

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:03:10 +02:00

86 lines
3.1 KiB
Python

"""Embedding generation for semantic search via pgvector.
Priority:
1. AWS Bedrock Titan Embed V2 (direct, best quality, 1024 dims)
2. LiteLLM proxy with configured LITELLM_EMBEDDING_MODEL (fallback)
"""
import logging
from app.config import settings
logger = logging.getLogger(__name__)
def _text_for_question(question_text: str, options: list[str] | None) -> str:
"""Build the text to embed for a question — stem + options, no explanation."""
parts = [question_text]
if options:
parts.extend(options)
return " ".join(parts)[:4000]
def generate_embedding(text: str) -> list[float] | None:
"""Generate a 1024-dim embedding.
Priority:
1. LiteLLM proxy (openai/titan-embed-v2) — scores ~0.71 cosine similarity
2. AWS Bedrock direct — fallback, scores ~0.48
"""
clean = " ".join(text.split())[:4000]
if not clean:
return None
# ── 1. LiteLLM proxy (best quality) ────────────────────────
if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY:
try:
import litellm
resp = litellm.embedding(
model=settings.LITELLM_EMBEDDING_MODEL,
input=[clean],
api_key=settings.LITELLM_API_KEY,
api_base=settings.LITELLM_API_BASE or None,
)
emb = resp.data[0]["embedding"]
if len(emb) == settings.EMBEDDING_DIMENSIONS:
return emb
# Dimension mismatch — don't store incompatible vector
logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}")
except Exception as e:
logger.warning(f"LiteLLM embedding failed: {e}")
# ── 2. AWS Bedrock Titan direct (fallback) ──────────────────
if settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY:
try:
import boto3, json
client = boto3.client(
"bedrock-runtime",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
region_name=settings.AWS_BEDROCK_REGION or "us-east-1",
)
body = json.dumps({
"inputText": clean,
"dimensions": settings.EMBEDDING_DIMENSIONS,
"normalize": True,
})
resp = client.invoke_model(
modelId="amazon.titan-embed-text-v2:0",
body=body,
contentType="application/json",
accept="application/json",
)
return json.loads(resp["body"].read())["embedding"]
except Exception as e:
logger.warning(f"Bedrock embedding failed: {e}")
return None
def embed_question(question) -> bool:
"""Generate and store embedding for a Question ORM object. Returns True on success."""
text = _text_for_question(question.question_text, question.options)
emb = generate_embedding(text)
if emb:
question.embedding = emb
return True
return False