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>
86 lines
3.1 KiB
Python
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
|