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

143 lines
5.7 KiB
Python

import os
import logging
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from services.url_utils import is_generic_homepage
logger = logging.getLogger(__name__)
FIRECRAWL_URL = os.getenv('FIRECRAWL_URL', 'http://firecrawl:3002')
CATEGORY_KEYWORDS = ['classic', 'trek', 'trail', 'safari', 'adventure', 'explorer', 'journey']
CONFIDENCE_FOUND_THRESHOLD = 0.3
MAX_CONCURRENT_MAP_CALLS = 5
def score_and_rank_urls(urls, destination, duration):
"""
Scores candidate URLs by keyword match against destination and
duration, returning the highest-scoring URL with a confidence score.
A URL must mention at least one destination keyword to be considered
at all — this is a hard gate, not an additive bonus. Regression found
in production: a URL for a completely different country could still
cross CONFIDENCE_FOUND_THRESHOLD purely from a duration-digit match
plus a generic category-keyword match (e.g. a Turkey trip page scored
0.75 confidence against a Peru search just because it happened to be a
"9 day classic" tour) — destination relevance must be a prerequisite,
not something duration/category signals alone can compensate for.
A bare root-domain URL (no path at all) is excluded the same way, for
the same reason: Firecrawl's /map candidate list commonly includes the
site's own root URL, and if the destination keyword happens to appear
in the domain itself (e.g. a competitor literally named
"peru-travel.com"), the root URL could otherwise score high enough to
be returned as a confident "found" match — silently presenting a
homepage as if it were a trip-specific page, the exact failure mode
this whole scoring function exists to prevent.
Data flow:
urls + destination + duration →
per-url: zero destination-keyword matches, or a bare root domain →
excluded entirely →
surviving urls scored: +1 per destination keyword in URL, +2 if
duration appears in URL, +1 per travel-category keyword in URL →
sort by score → confidence = top_score / max_possible_score →
{ url, confidence, found } — found=False (prompt manual entry)
when no url survives the gate, or confidence is below
CONFIDENCE_FOUND_THRESHOLD
"""
dest_keywords = destination.lower().split()
scores = []
for url in urls:
if is_generic_homepage(url):
continue # bare root domain — never a trip-specific candidate
url_lower = url.lower()
dest_matches = sum(1 for kw in dest_keywords if kw in url_lower)
if dest_matches == 0:
continue # zero destination relevance — never a candidate, regardless of duration/category signal
score = dest_matches
if str(duration) in url_lower:
score += 2
score += sum(1 for kw in CATEGORY_KEYWORDS if kw in url_lower)
scores.append((score, url))
scores.sort(key=lambda pair: pair[0], reverse=True)
if not scores:
return {'url': None, 'confidence': 0, 'found': False}
top_score, top_url = scores[0]
max_possible = len(dest_keywords) + 2 + 1
confidence = min(top_score / max_possible, 1.0) if max_possible else 0
return {
'url': top_url,
'confidence': round(confidence, 2),
'found': confidence >= CONFIDENCE_FOUND_THRESHOLD,
}
def find_competitor_trip_url(root_url, destination, duration, travel_style):
"""
Uses Firecrawl /map with search terms to find the most likely trip
page on a competitor site matching the PM's research intent.
Data flow:
root_url + destination/duration/travel_style →
POST {FIRECRAWL_URL}/v1/map with a search string →
candidate URL list → score_and_rank_urls() →
{ url, confidence, found } returned (found=False, url=None on any
Firecrawl failure — never raises, since one competitor's failure
must not block the others per CLAUDE.md §18 resilience rules)
"""
try:
response = requests.post(
f'{FIRECRAWL_URL}/v1/map',
json={'url': root_url, 'search': f'{destination} {duration} days {travel_style} tour'},
timeout=15
)
response.raise_for_status()
urls = response.json().get('links', [])
except requests.RequestException as e:
logger.error(f'Firecrawl /map failed for {root_url}: {str(e)}')
return {'url': None, 'confidence': 0, 'found': False}
if not urls:
return {'url': None, 'confidence': 0, 'found': False}
return score_and_rank_urls(urls, destination, duration)
def find_all_competitor_urls(competitors, destination, duration, travel_style):
"""
Runs trip URL finding for all selected competitors concurrently —
at most MAX_CONCURRENT_MAP_CALLS Firecrawl /map calls in flight at
once, respecting server limits.
Data flow:
competitors list → ThreadPoolExecutor(max_workers=5) →
find_competitor_trip_url() per competitor in parallel →
collect results as they complete → matches list returned
(one entry per competitor, in completion order)
"""
results = []
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_MAP_CALLS) as executor:
futures = {
executor.submit(
find_competitor_trip_url, c['website'], destination, duration, travel_style
): c
for c in competitors
}
for future in as_completed(futures):
competitor = futures[future]
match = future.result()
results.append({
'competitor_id': competitor['id'],
'competitor_name': competitor['name'],
**match,
})
return results