app.bettersight.io/backend/core/extractor.py

203 lines
9.1 KiB
Python

import os
import re
import json
import time
import logging
from openai import OpenAI
import sentry_sdk
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY')
FREE_MODELS = [m.strip() for m in os.getenv('OPENROUTER_FREE_MODELS', '').split(',') if m.strip()]
FALLBACK_MODEL = os.getenv('OPENROUTER_FALLBACK_MODEL', 'anthropic/claude-haiku-4-5')
# MVP launch toggle — set OPENROUTER_USE_FREE_MODELS_FIRST=false to skip
# FREE_MODELS entirely and call FALLBACK_MODEL directly. Added after a
# real test job took 5m47s because OpenRouter's free tier was rotating
# through rate-limited/dead models per extraction call, which blew past
# the frontend's polling timeout. Flip back to "true" (or unset — that's
# the default) once free-tier reliability is worth the latency again;
# FREE_MODELS itself is left untouched so switching back is a one-line change.
USE_FREE_MODELS_FIRST = os.getenv('OPENROUTER_USE_FREE_MODELS_FIRST', 'true').lower() == 'true'
class ExtractorError(Exception):
"""Raised when every OpenRouter model (free tier + paid fallback) has failed."""
pass
class RateLimitError(Exception):
"""Raised internally when an OpenRouter model returns a 429 / rate-limit response."""
pass
class CreditExhaustedError(Exception):
"""
Raised internally when a free model's own credit/quota is used up
for the period (403 + "credit" in the error body) — distinct from
RateLimitError: a 429 is transient and worth a short backoff+retry
on the same model, but an exhausted free-tier quota won't recover
within this request, so the caller should move to the next model
immediately instead of wasting a retry.
"""
pass
def _parse_json_response(text, model, label):
"""
Parses a model's raw text response into JSON via progressively more
aggressive repairs — ported unchanged from the legacy app.py
extraction logic: raw parse → regex object extraction →
trailing-comma/unquoted-key repair.
Data flow:
raw model text → strip code fences →
json.loads() (fast path) →
regex-extract the outermost {...} block and retry →
strip trailing commas + quote bare keys and retry →
parsed dict returned, or ValueError if all three fail
"""
cleaned = re.sub(r'```json|```', '', text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
match = re.search(r'\{[\s\S]*\}', cleaned)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
try:
fixed = re.sub(r',\s*}', '}', cleaned)
fixed = re.sub(r',\s*]', ']', fixed)
fixed = re.sub(r'([{,])\s*([a-zA-Z0-9_]+)\s*:', r'\1"\2":', fixed)
return json.loads(fixed)
except json.JSONDecodeError:
# All three repair passes failed — log what the model actually
# returned so a real production failure is diagnosable from
# worker logs alone. Without this, "Could not parse response"
# gives no way to tell truncation, prose-wrapped JSON, and
# genuinely malformed output apart after the fact.
logger.warning(f'{model} unparseable response for {label} (first 1000 chars): {cleaned[:1000]!r}')
raise ValueError(f'Could not parse response from {model} for {label}')
def _call_openrouter(model, prompt, content):
"""
Issues one chat completion request to OpenRouter for `model`.
`content` is appended to `prompt` when present — most callers
(via industries.adventure_travel.prompt.build_prompt) already embed
page content directly into `prompt`, so `content` is typically
empty; analysis_service's refresh path passes it explicitly too,
which is harmless (the model just sees the page text once via the
prompt and, redundantly, once more here).
response_format={'type': 'json_object'} constrains the model to
return valid JSON at the API level, rather than relying purely on
the prompt's "Return ONLY a valid JSON object" instruction. Added
after a real production failure: deepseek/deepseek-v4-flash would
write a full prose comparative analysis (markdown headers, tables,
"Key Differentiators" sections) instead of the requested JSON
whenever the competitor's page content didn't clearly match the
requested trip — the model prioritized being "helpful" about
explaining the mismatch over following the format instruction. This
wasn't reproducible with a short synthetic prompt, only the full
production prompt (many instructions, long page content) — so this
is a systemic, defensive fix for the whole class of "model deviates
into prose" failures, not just the one reproduced case. Confirmed
compatible with every model currently configured (OPENROUTER_FREE_MODELS
+ OPENROUTER_FALLBACK_MODEL) — a model that rejected the parameter
would 400 immediately, not the 429s seen during compatibility testing.
Data flow:
model + prompt + content → OpenAI client (OpenRouter base_url) →
chat.completions.create() → raise RateLimitError on 429/rate-limit
responses, CreditExhaustedError on a 403 quota-exhausted response
(caller reacts differently to each — see extract_with_openrouter) →
raw text → _parse_json_response() → parsed dict returned
"""
client = OpenAI(api_key=OPENROUTER_API_KEY, base_url='https://openrouter.ai/api/v1')
message = f'{prompt}\n\n{content}' if content else prompt
try:
response = client.chat.completions.create(
model=model,
messages=[{'role': 'user', 'content': message}],
temperature=0.1,
max_tokens=4096,
response_format={'type': 'json_object'}
)
except Exception as e:
error_str = str(e)
if '403' in error_str and 'credit' in error_str.lower():
raise CreditExhaustedError(error_str)
if '429' in error_str or 'rate_limit' in error_str.lower():
raise RateLimitError(error_str)
raise
text = response.choices[0].message.content
return _parse_json_response(text, model, 'extraction')
RETRY_ATTEMPTS_PER_MODEL = 2 # ported from the legacy app.py's extract_with_groq()
def extract_with_openrouter(prompt, content=''):
"""
Cycles through free OpenRouter models in sequence, retrying each
one up to RETRY_ATTEMPTS_PER_MODEL times with backoff before moving
to the next — ported from the legacy app.py's extract_with_groq(),
which the Phase 2 refactor had dropped (it moved to the next model
on the very first error of any kind, including a transient 429,
burning through the whole free-tier list instantly on any rate-limit
burst). Falls back to the paid fallback model if every free model
is exhausted. Never returns None — raises ExtractorError only after
every model (free + paid) has failed.
When USE_FREE_MODELS_FIRST is false, FREE_MODELS is skipped
entirely and FALLBACK_MODEL is called directly — see that
constant's comment for why.
Data flow:
prompt + content → for each model in FREE_MODELS + [FALLBACK_MODEL]
(or just [FALLBACK_MODEL] when USE_FREE_MODELS_FIRST is false):
up to RETRY_ATTEMPTS_PER_MODEL attempts:
_call_openrouter() → success: return parsed JSON
(logging + a Sentry breadcrumb if free models were tried and
exhausted before the paid fallback succeeded) →
RateLimitError: sleep 5*(attempt+1)s, retry same model →
CreditExhaustedError: log, move to next model immediately
(quota won't recover within this request — retrying wastes time) →
any other error: retry same model up to the attempt limit,
then move to next model →
all models exhausted → raise ExtractorError
"""
models_to_try = FREE_MODELS + [FALLBACK_MODEL] if USE_FREE_MODELS_FIRST else [FALLBACK_MODEL]
for model in models_to_try:
for attempt in range(RETRY_ATTEMPTS_PER_MODEL):
try:
result = _call_openrouter(model, prompt, content)
if USE_FREE_MODELS_FIRST and model == FALLBACK_MODEL and FREE_MODELS:
logger.warning(f'Free models exhausted — used paid fallback: {model}')
sentry_sdk.capture_message('OpenRouter paid fallback used', level='warning')
return result
except RateLimitError:
wait = 5 * (attempt + 1)
logger.warning(f'{model} rate limited — waiting {wait}s (attempt {attempt + 1}/{RETRY_ATTEMPTS_PER_MODEL})...')
time.sleep(wait)
except CreditExhaustedError:
logger.warning(f'{model} free tier exhausted — trying next model...')
break
except Exception as e:
logger.warning(f'{model} failed: {str(e)[:200]}')
if attempt == RETRY_ATTEMPTS_PER_MODEL - 1:
logger.warning(f'Moving to next model after {model} failed {RETRY_ATTEMPTS_PER_MODEL} times')
raise ExtractorError('All OpenRouter models exhausted including paid fallback')