pdf-quiz-generator/backend/app/services/ai_service.py
Daniel a1459b2965 refactor: name the study plans ourselves, and stop reserving 64k tokens a call
"PREP" is the American Academy of Pediatrics' trademark for their own product.
The plans here are our own sets of questions grouped by year, so they are now
named for what they are: Board Review 2021, and Mixed Review for the plan that
draws from every year at once.

Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had
already generated from a block, and the 12 year tags, which appear in the
question bank's filters and are as visible as the plans. The seeder matches both
the old and new names so a fresh import still finds its material, and the tagger
mints the new one so the next run cannot undo this. Prompts and comments that
described the source PDFs by that name now describe them by what they are.

The generation run's 377 failures were not a bug
Every call was reserving the model's full 64k output ceiling, and OpenRouter
refuses the whole request when the balance is below the reservation — "you
requested up to 64000 tokens, but can only afford 52017" — however short the
answer would actually be. `_call_model` now takes a max_tokens, and the article
writer asks for 4000, which is comfortable for three views of one topic and
keeps each request small enough to be affordable. 98 articles were written
before the balance ran down; 158 exist in total.

Generation is paused at the user's request while credits are topped up.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 01:41:18 +02:00

530 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
import litellm
from app.config import settings
logger = logging.getLogger(__name__)
def _proxy_model(model_id: str) -> str:
"""Always prefix model with openai/ when using a proxy. The proxy (Bifrost)
expects provider/model format; litellm strips ONE openai/ prefix before
sending to OpenAI-compatible endpoints, so we wrap with an extra openai/."""
if settings.LITELLM_API_BASE:
return f"openai/{model_id}"
return model_id
EXTRACTION_PROMPT = """You are extracting questions from a pediatric board review exam PDF.
These PDFs follow a strict format:
1. A numbered question with a clinical vignette (patient scenario)
2. Five answer options labeled A, B, C, D, E
3. A line "Correct Answer: X" or "Preferred Response: X" where X is the letter of the correct option
4. An explanation paragraph
5. A "Critique:" section with detailed reasoning
6. A "Content Specifications:" section listing the learning objectives
Your task: extract every question and return ONLY a JSON object in this exact format:
{{"questions": [
{{
"item_number": "<item number digits only, e.g. '193' — null if not found>",
"question_text": "<full question stem including any patient vignette>",
"question_type": "mcq",
"options": ["<option A text>", "<option B text>", "<option C text>", "<option D text>", "<option E text>"],
"correct_answer": "<full text of the correct option, NOT the letter>",
"explanation": "<ALL text verbatim from after the correct answer line to before the next question — explanation, Critique, Learning Points, Content Specifications, Suggested Reading, everything>",
"has_figure": false,
"page_reference": {page_ref}
}}
]}}
CRITICAL RULES — follow exactly:
1. CORRECT ANSWER: Find "Correct Answer: X" or "Preferred Response: X" after each question.
X is a letter (AE). Look up the full text of that option and store it as correct_answer.
NEVER store just the letter. NEVER guess. If not found, set correct_answer to null.
Example: options=["alpha","beta","gamma","delta","epsilon"] and "Correct Answer: C" → correct_answer="gamma"
2. EXPLANATION: Copy EVERYTHING that appears after the correct answer line and before the next question,
verbatim and in full — including the explanation paragraph, any Critique section, Learning Points,
Content Specifications, American Board of Pediatrics specifications, Suggested Readings, and any
other content. Do NOT summarize, shorten, skip, or paraphrase a single word.
Keep all section headers (e.g. "Critique:", "Content Specifications:", "Suggested Reading:").
The explanation field should be the complete, unaltered text block.
3. QUESTIONS: Extract ALL questions, even if partially cut off.
A new question starts with "Item NNN" or "ltem NNN" (OCR artifact — lowercase l instead of I).
Content after an image reference (e.g. "Item C123A", figure captions) is NOT a new question.
4. OPTIONS: Extract just the text for each option, without the letter prefix (A. B. C. D. E.).
5. HAS_FIGURE: Set to true ONLY if the question text explicitly references an image, figure, photograph,
radiograph, X-ray, ECG, rash photo, growth chart, or similar visual that is essential to answering.
Do NOT set true for decorative logos, page headers, publisher icons, or repeated branding images.
If the question can be answered from text alone, set false.
6. Return ONLY the JSON — no markdown fences, no explanation, no preamble.
Content from page(s) {page_info}:
{content}"""
ANSWER_KEY_PROMPT = """Extract the answer key from this board review exam content.
The answer key lists items with their correct answer letters, like:
"Item 193 Preferred Response: D"
"Item 194 Preferred Response: A"
Return ONLY a JSON object mapping item numbers to correct letters:
{{"answers": {{"193": "D", "194": "A", "211": "C"}}}}
Rules:
- "Preferred Response: X" or "Correct Answer: X" — X is the letter.
- Item numbers may appear as "ltemXXX" (OCR artifact — l is actually I).
- Only include items where you find a clear correct answer letter.
- Return ONLY the JSON — no markdown, no preamble.
Content from page(s) {page_info}:
{content}"""
def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
"""Get the configured model for a specific task from DB, or fall back to settings."""
try:
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
if config:
return config.model_id, config.api_key
except Exception as e:
logger.warning(f"Failed to load AI model config for task '{task}', using fallback: {e}")
return settings.LITELLM_MODEL, settings.LITELLM_API_KEY or None
def _truncate_content(content: str, max_chars: int = 100000) -> str:
if len(content) <= max_chars:
return content
half = max_chars // 2
return content[:half] + "\n\n... [content truncated] ...\n\n" + content[-half:]
def extract_questions(
content: str,
page_info: str = "unknown",
page_ref: int | None = None,
model_id: str | None = None,
api_key: str | None = None,
) -> list[dict]:
"""Extract quiz questions from PDF content using LiteLLM."""
content = _truncate_content(content)
prompt = EXTRACTION_PROMPT.format(
content=content,
page_info=page_info,
page_ref=page_ref if page_ref else "null",
)
use_model = _proxy_model(model_id or settings.LITELLM_MODEL)
use_key = api_key or settings.LITELLM_API_KEY
last_error = None
for attempt in range(3):
try:
kwargs = {
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1, # low temp for faithful extraction
}
if use_key:
kwargs["api_key"] = use_key
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
# Don't force JSON mode — let the model respond naturally and we parse it
response = litellm.completion(**kwargs)
response_text = response.choices[0].message.content
logger.info(f"Model raw response (first 500 chars): {response_text[:500]!r}")
# Try to parse JSON, handle markdown code blocks
text = response_text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
# Handle all common response shapes
if isinstance(data, list):
questions = data
elif isinstance(data, dict):
# Try common keys
for key in ("questions", "items", "results", "data"):
if isinstance(data.get(key), list):
questions = data[key]
break
else:
# Maybe the dict itself is a single question
if "question_text" in data:
questions = [data]
else:
raise ValueError(f"Unexpected response shape: {list(data.keys())}")
else:
raise ValueError("Response is not a list of questions")
validated = []
skipped = []
for q in questions:
if "question_text" not in q:
continue
correct = q.get("correct_answer")
if not correct:
skipped.append(q.get("question_text", "")[:120])
continue
qtype = q.get("question_type", "mcq")
if qtype not in ("mcq", "true_false", "fill_blank"):
qtype = "mcq"
validated.append({
"question_text": q["question_text"],
"question_type": qtype,
"options": q.get("options"),
"correct_answer": correct,
"explanation": q.get("explanation", ""),
"page_reference": q.get("page_reference"),
"skipped": [],
})
# Attach skipped list to first question so caller can surface it
if validated and skipped:
validated[0]["skipped"] = skipped
if validated:
return validated
raise ValueError("No valid questions extracted from content")
except Exception as e:
last_error = e
logger.warning(f"Extraction attempt {attempt + 1} failed: {e!r}")
raise RuntimeError(f"Failed to extract questions after 3 attempts: {last_error}")
def _call_model(prompt: str, model_id: str | None, api_key: str | None,
timeout: int = 180, max_tokens: int | None = None) -> str:
"""Call the configured LLM and return raw text response.
The timeout is not optional in practice: every other call in this module has
one, and this one did not. A stalled connection to the proxy hung the caller
for good — which an interactive request survives by the user giving up, and
an unattended run of several hundred topics does not.
`max_tokens` is worth setting for the same reason. Left unset, the request
reserves the model's full output ceiling — 64k on the current default — and
a provider that bills against reserved capacity refuses the whole call when
the balance is below that, however short the answer would actually be.
"""
use_model = _proxy_model(model_id or settings.LITELLM_MODEL)
use_key = api_key or settings.LITELLM_API_KEY
kwargs = {
"model": use_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"timeout": timeout,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
if use_key:
kwargs["api_key"] = use_key
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
response = litellm.completion(**kwargs)
return response.choices[0].message.content
def _parse_json_response(text: str) -> dict:
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
return json.loads(text)
def transcribe_audio(
audio: bytes,
filename: str = "audio.webm",
content_type: str = "audio/webm",
model_id: str | None = None,
api_key: str | None = None,
) -> str | None:
"""Transcribe uploaded audio through LiteLLM's OpenAI-compatible audio endpoint."""
import httpx
if not audio:
return None
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for STT")
return None
key = api_key or settings.LITELLM_API_KEY
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
resp = httpx.post(
f"{base}/v1/audio/transcriptions",
headers=headers,
data={"model": model_id or "local-parakeet-v3", "response_format": "json"},
files={"file": (filename or "audio.webm", audio, content_type or "application/octet-stream")},
timeout=120,
)
resp.raise_for_status()
try:
data = resp.json()
if isinstance(data, dict):
return str(data.get("text") or data.get("transcript") or data.get("transcription") or "").strip()
except ValueError:
return resp.text.strip()
except Exception as e:
logger.error(f"LiteLLM STT failed: {e}")
return None
def extract_questions_no_answers(
content: str,
page_info: str = "unknown",
page_ref: int | None = None,
model_id: str | None = None,
api_key: str | None = None,
) -> list[dict]:
"""Extract questions allowing null correct_answer — for PDFs where answers are at the end."""
content = _truncate_content(content)
prompt = EXTRACTION_PROMPT.format(
content=content,
page_info=page_info,
page_ref=page_ref if page_ref else "null",
)
last_error = None
for attempt in range(3):
try:
text = _call_model(prompt, model_id, api_key)
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
questions = []
if isinstance(data, list):
questions = data
elif isinstance(data, dict):
for key in ("questions", "items", "results", "data"):
if isinstance(data.get(key), list):
questions = data[key]
break
else:
if "question_text" in data:
questions = [data]
result = []
for q in questions:
if "question_text" not in q:
continue
qtype = q.get("question_type", "mcq")
if qtype not in ("mcq", "true_false", "fill_blank"):
qtype = "mcq"
result.append({
"item_number": str(q.get("item_number") or "").strip().lstrip("0") or None,
"question_text": q["question_text"],
"question_type": qtype,
"options": q.get("options"),
"correct_answer": q.get("correct_answer"), # may be null
"explanation": q.get("explanation", ""),
"page_reference": q.get("page_reference"),
})
if result:
return result
raise ValueError("No questions found in content")
except Exception as e:
last_error = e
logger.warning(f"No-answer extraction attempt {attempt + 1} failed: {e!r}")
raise RuntimeError(f"Failed after 3 attempts: {last_error}")
def extract_answer_key(
content: str,
page_info: str = "unknown",
model_id: str | None = None,
api_key: str | None = None,
) -> dict[str, str]:
"""Extract answer key from end-of-document content. Returns {item_number: letter}."""
content = _truncate_content(content, max_chars=80000)
prompt = ANSWER_KEY_PROMPT.format(content=content, page_info=page_info)
last_error = None
for attempt in range(3):
try:
text = _call_model(prompt, model_id, api_key)
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
answers = data.get("answers", data) if isinstance(data, dict) else {}
# Normalize: strip leading zeros, uppercase letters
return {str(k).strip().lstrip("0"): str(v).strip().upper() for k, v in answers.items() if v}
except Exception as e:
last_error = e
logger.warning(f"Answer key extraction attempt {attempt + 1} failed: {e!r}")
logger.warning(f"Answer key extraction failed: {last_error}")
return {}
def generate_tts_audio(
text: str,
model_id: str | None = None,
api_key: str | None = None,
) -> bytes | None:
"""Generate TTS audio. Supports local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS.
model_id conventions:
tts-1:alloy → OpenAI TTS (voice after colon)
tts-1-hd:nova → OpenAI TTS HD
elevenlabs/<voice> → ElevenLabs
google/<voice_name> → Google Cloud TTS (e.g. google/en-US-Wavenet-D)
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
local-<model>:<voice> → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam)
"""
import httpx, base64
use_model = model_id or "tts-1:alloy"
# ── Local LiteLLM TTS models ─────────────────────────────────
if use_model.startswith("local-"):
local_model = use_model
local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy"
if ":" in use_model:
local_model, local_voice = use_model.split(":", 1)
key = api_key or settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for local TTS")
return None
try:
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local LiteLLM TTS failed: {e}")
return None
# ── Local Sherpa gateway ───────────────────────────────────
if use_model.startswith("sherpa/"):
payload = use_model[len("sherpa/"):]
profile = payload
voice = "0"
if ":" in payload:
profile, voice = payload.split(":", 1)
try:
resp = httpx.post(
f"{settings.LOCAL_SPEECH_GATEWAY_URL.rstrip('/')}/v1/audio/speech",
json={"model": f"sherpa/{profile}", "input": text, "voice": voice, "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local Sherpa TTS failed: {e}")
return None
# ── ElevenLabs ─────────────────────────────────────────────
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
voice = use_model.split("/", 1)[1]
key = api_key or settings.ELEVENLABS_API_KEY
if not key:
logger.error("ElevenLabs API key not configured")
return None
try:
resp = httpx.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice}",
headers={"xi-api-key": key, "Content-Type": "application/json"},
json={"text": text, "model_id": "eleven_turbo_v2_5"},
timeout=30,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"ElevenLabs TTS failed: {e}")
return None
# ── Google Cloud TTS ────────────────────────────────────────
if use_model.startswith("google/"):
voice_name = use_model[len("google/"):]
key = api_key or settings.GOOGLE_TTS_API_KEY
if not key:
logger.error("Google TTS API key not configured (GOOGLE_TTS_API_KEY)")
return None
# Parse language code from voice name (e.g. "en-US-Wavenet-D" → "en-US")
parts = voice_name.split("-")
lang_code = f"{parts[0]}-{parts[1]}" if len(parts) >= 2 else "en-US"
try:
resp = httpx.post(
f"https://texttospeech.googleapis.com/v1/text:synthesize?key={key}",
json={
"input": {"text": text},
"voice": {"languageCode": lang_code, "name": voice_name},
"audioConfig": {"audioEncoding": "MP3"},
},
timeout=30,
)
resp.raise_for_status()
return base64.b64decode(resp.json()["audioContent"])
except Exception as e:
logger.error(f"Google TTS failed: {e}")
return None
# ── OpenAI (default) ────────────────────────────────────────
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
clean_model = use_model.replace("openai/", "")
oai_voice = "alloy"
if ":" in clean_model:
clean_model, oai_voice = clean_model.split(":", 1)
# Per-model key > OPENAI_API_KEY (direct) > LITELLM_API_KEY (proxy)
if api_key:
key = api_key
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1")
elif settings.OPENAI_API_KEY:
key = settings.OPENAI_API_KEY
base = "https://api.openai.com"
else:
key = settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1")
try:
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json={"model": clean_model, "input": text, "voice": oai_voice},
timeout=60,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"OpenAI TTS failed: {e}")
return None