app.bettersight.io/backend/services/embedding_service.py

132 lines
5.2 KiB
Python

import os
import logging
import numpy as np
from repositories.client_trips_repository import client_trips_repository
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY')
EMBEDDING_MODEL = 'openai/text-embedding-3-small'
SIMILARITY_THRESHOLD = 0.75
def save_client_trip(tenant_id, destination, duration, travel_style, product_name=None):
"""
Upserts a client_trips record from a PM's trip-intent description
(CLAUDE.md §8's TripIntentForm — destination/duration/travel_style,
optionally a product name). Every /research submission calls this,
which is the only place client_trips ever gets populated — without
it, find_comparable_trips() has nothing on the client side to match
competitor products against.
Dedupes on (tenant_id, trip_name) so re-running analysis for the
same product doesn't create duplicate rows — it just keeps the
existing one current.
Data flow:
destination/duration/travel_style/product_name →
trip_name resolved (product_name if given, else a
destination+style fallback) →
client_trips_repository.get_by_tenant_and_name() →
found: update in place → not found: create →
client_trips record returned
"""
trip_name = product_name or f'{destination} {travel_style}'.strip()
payload = {
'tenant_id': tenant_id,
'trip_name': trip_name,
'destination': destination,
'duration_days': duration,
}
existing = client_trips_repository.get_by_tenant_and_name(tenant_id, trip_name)
if existing:
# A stale embedding would silently never get regenerated —
# /internal/embed-products only embeds trips missing a vector —
# so clear it whenever the text embed_trip() hashes actually changed.
if existing.get('destination') != destination or existing.get('duration_days') != duration:
payload['embedding'] = None
return client_trips_repository.update(existing['id'], payload)
return client_trips_repository.create(payload)
def embed_trip(trip):
"""
Generates a semantic embedding for a trip using OpenRouter's
text-embedding-3-small, combining trip name, destination, duration,
and activities into a single text representation first.
Data flow:
trip dict → formatted text string → OpenRouter embeddings API →
vector list returned for storage in products.embedding /
client_trips.embedding
"""
# Imported lazily so this module has no hard dependency on the
# `openai` package at import time for callers that only need
# find_comparable_trips() (pure math, no API calls).
from openai import OpenAI
text = (
f"{trip.get('trip_name', '')}\n"
f"Destination: {trip.get('destination', '')}\n"
f"Duration: {trip.get('duration_days', '')} days\n"
f"Activities: {trip.get('activities', '')}\n"
f"Start: {trip.get('start_location', '')}"
)
client = OpenAI(api_key=OPENROUTER_API_KEY, base_url='https://openrouter.ai/api/v1')
response = client.embeddings.create(model=EMBEDDING_MODEL, input=text)
return response.data[0].embedding
def _cosine_similarity(a, b):
"""Standard cosine similarity between two equal-length embedding vectors."""
a, b = np.array(a), np.array(b)
denom = (np.linalg.norm(a) * np.linalg.norm(b))
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
def find_comparable_trips(client_trips, competitor_trips, threshold=SIMILARITY_THRESHOLD):
"""
Calculates cosine similarity between every client trip and every
competitor trip, returning matches above threshold sorted by
similarity score descending.
Data flow:
client_trips + competitor_trips (each with .embedding) →
pairwise cosine similarity → filter by threshold →
sort by score descending → list of match dicts returned
"""
matches = []
for client_trip in client_trips:
client_embedding = client_trip.get('embedding')
if not client_embedding:
continue
for competitor_trip in competitor_trips:
competitor_embedding = competitor_trip.get('embedding')
if not competitor_embedding:
continue
score = _cosine_similarity(client_embedding, competitor_embedding)
if score >= threshold:
matches.append({
'client_product': client_trip.get('trip_name'),
'competitor_product': competitor_trip.get('id'),
'similarity_score': round(score, 4),
'price_difference': _safe_diff(
competitor_trip.get('majority_price_usd'), client_trip.get('price_usd')
),
'duration_difference': _safe_diff(
competitor_trip.get('duration_days'), client_trip.get('duration_days')
),
})
matches.sort(key=lambda m: m['similarity_score'], reverse=True)
return matches
def _safe_diff(a, b):
"""Returns a - b if both are present, else None — keeps the diff fields nullable per schema."""
if a is None or b is None:
return None
return a - b