pdf-quiz-generator/backend/app/tasks/quiz_tasks.py
Daniel cdf4ab80b4
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 34s
Tests / e2e (push) Has been cancelled
fix: the figures route handed out the answer side to anybody signed in
GET /questions/detail/{id}/figures had no check at all, sitting next to
a detail route that has one. Any signed-in account could ask for any
question's figures by id and be handed its explanation images: the
paths, and the library record that now rides on them — whose titles run
to "Neonatal Herpes Simplex · Q874". No attempt required, and the answer
in the title. Found by walking today's surfaces as a real learner
account rather than reading the guards.

Same rule as the route beside it: the stem is readable in the bank, the
answer side belongs to whoever writes the question.

Also, refining an article no longer breaks the links into it. The refine
path replaces the whole section list and the model was never shown the
existing ids, so it invented fresh ones — silently breaking every
`[[95#id]]` pointing at a section, from another article, a question's
key point or a study plan's reading. The id travels in the heading now
and the prompt says to return it unchanged for any section kept. The
model is also told to leave existing `[[123|links]]` exactly as written
and never to invent one, because a guessed number points at nothing.

And the section strip is centred. Widening its box to 1600px let the
links spread but `flex: 1` on the strip — right for every other strip on
the site — filled the whole box with the links against its left edge:
measured at 1500px, they began 70px left of the page content while the
strip ran 180px past its right. Content-sized and centred now, still
scrolling when the links genuinely outrun the window. Diagnosis from the
ped-ai session; verified at 1500 and 1920 with no arrows at either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:29:29 +02:00

1280 lines
62 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.

"""Async quiz extraction task with step-by-step progress reporting via Redis."""
import json
import logging
import re
import time
import os
from sqlalchemy import or_, text as sa_text
from app.tasks import celery_app
from app.database import SessionLocal
logger = logging.getLogger(__name__)
EXPIRE_SECONDS = 3600
CHUNK_PAGES = 50
def _redis():
import redis
from app.config import settings
return redis.from_url(settings.REDIS_URL, decode_responses=True)
def _push_step(r, job_id: str, step: str, message: str):
key = f"extraction:steps:{job_id}"
entry = json.dumps({"step": step, "message": message, "ts": time.time()})
r.rpush(key, entry)
r.expire(key, EXPIRE_SECONDS)
def _normalize_ocr(text: str) -> str:
"""Fix common OCR artifacts in the source PDFs."""
return (text
.replace("Pref erred", "Preferred")
.replace("Pre ferred", "Preferred")
.replace("Prefer red", "Preferred")
.replace("ltem", "Item")
.replace("ltcm", "Item"))
@celery_app.task(name="extract_quiz", bind=True)
def extract_quiz(
self,
job_id: str,
user_id: int,
section_id: int,
title: str,
mode: str,
time_limit_minutes: int | None,
model_id: str | None,
question_category_id: int | None,
extraction_mode: str = "standard",
):
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.services import ai_service, vector_service, pdf_service, embedding_service
from app.models.quiz import Quiz
from app.models.question import Question
from app.models.quiz_question_link import QuizQuestionLink
from app.config import settings
_push_step(r, job_id, "start", "Starting extraction…")
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
raise ValueError("Section not found")
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
if not document:
raise ValueError("Document not found")
total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "text", f"Loading text from pages {section.start_page}{section.end_page} ({total_pages} pages)…")
# Determine model
if model_id:
from app.models.ai_model_config import AIModelConfig
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
api_key = config.api_key if config and config.api_key else None
model_name = config.name if config else model_id
else:
model_id, api_key = ai_service.get_model_for_task(db, "extraction")
model_name = model_id
# Split into 50-page chunks
if total_pages <= CHUNK_PAGES:
chunks = [(section.start_page, section.end_page)]
else:
chunks = []
p = section.start_page
while p <= section.end_page:
end = min(p + CHUNK_PAGES - 1, section.end_page)
chunks.append((p, end))
p = end + 1
n_chunks = len(chunks)
if n_chunks > 1:
_push_step(r, job_id, "text", f"Large section: splitting into {n_chunks} chunks of up to {CHUNK_PAGES} pages each.")
all_valid_questions = []
# A page whose text is missing is a different failure from a page the
# model found nothing in, and the two were reported as one.
pages_without_text = 0
all_skipped = []
# ── Non-standard extraction modes ─────────────────────────────────────
if extraction_mode != "standard":
from app.services.extraction_modes import (
extract_questions_only, extract_two_step,
extract_with_regex, ai_decide_strategy, generate_from_text,
ai_answer_questions,
)
resolved_mode = extraction_mode
if extraction_mode == "ai_decide":
_push_step(r, job_id, "ai", "AI is analysing the document to choose the best strategy…")
resolved_mode, reasoning = ai_decide_strategy(
section.document_id, section.start_page, section.end_page,
model_id, api_key,
)
_push_step(r, job_id, "ai", f"AI chose: {resolved_mode}{reasoning}")
if resolved_mode == "questions_only":
_push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p)
if not chunk_content:
continue
try:
qs = extract_questions_only(_normalize_ocr(chunk_content),
f"{start_p}-{end_p}", start_p, model_id, api_key)
all_valid_questions.extend(qs)
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {len(qs)} questions. Total: {len(all_valid_questions)}.")
except Exception as e:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p} failed: {e}")
elif resolved_mode == "ai_answer":
_push_step(r, job_id, "ai", "Mode: AI Answer — extracting questions and using AI to determine correct answers.")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p} — extracting questions…")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p)
if not chunk_content:
continue
try:
normalized = _normalize_ocr(chunk_content)
qs = extract_questions_only(normalized, f"{start_p}-{end_p}", start_p, model_id, api_key)
if qs:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {len(qs)} questions found, AI determining answers…")
qs = ai_answer_questions(qs, normalized, f"{start_p}-{end_p}", model_id, api_key)
answered = sum(1 for q in qs if q.get("correct_answer") and q["correct_answer"] != "PENDING")
all_valid_questions.extend(qs)
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {answered}/{len(qs)} answered. Total: {len(all_valid_questions)}.")
except Exception as e:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p} failed: {e}")
elif resolved_mode == "two_step":
_push_step(r, job_id, "ai", "Mode: Two-Step (separate answer key section).")
all_valid_questions, all_skipped = extract_two_step(
section.document_id, section.start_page, section.end_page,
model_id, api_key,
push_step=lambda step, msg: _push_step(r, job_id, step, msg),
chunk_pages=CHUNK_PAGES,
)
elif resolved_mode == "regex":
_push_step(r, job_id, "ai", "Mode: AI+Regex — analysing format then applying regex.")
all_valid_questions, all_skipped = extract_with_regex(
section.document_id, section.start_page, section.end_page,
model_id, api_key,
push_step=lambda step, msg: _push_step(r, job_id, step, msg),
chunk_pages=CHUNK_PAGES,
)
elif resolved_mode == "generate":
_push_step(r, job_id, "ai", "Mode: Generate — AI creates questions from the text.")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p)
if not chunk_content:
continue
try:
qs = generate_from_text(_normalize_ocr(chunk_content),
f"{start_p}-{end_p}", start_p, model_id, api_key)
all_valid_questions.extend(qs)
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {len(qs)} questions generated. Total: {len(all_valid_questions)}.")
except Exception as e:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p} failed: {e}")
else:
# ai_decide resolved to standard — fall through to standard loop below
extraction_mode = "standard"
if extraction_mode == "standard":
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
if n_chunks > 1:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}{model_name}")
else:
_push_step(r, job_id, "ai", f"Sending pages {start_p}{end_p} to {model_name}")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p,
)
if not chunk_content:
pages_without_text += 1
_push_step(r, job_id, "ai",
f" No stored text for pages {start_p}{end_p}. The document's text is"
f" read from the search index, not the file, so this usually means it"
f" was never processed or its index was lost.")
continue
try:
chunk_data = ai_service.extract_questions(
_normalize_ocr(chunk_content),
page_info=f"{start_p}-{end_p}",
page_ref=start_p,
model_id=model_id,
api_key=api_key,
)
chunk_skipped = chunk_data[0].pop("skipped", []) if chunk_data else []
chunk_valid = [q for q in chunk_data if q.get("correct_answer")]
all_valid_questions.extend(chunk_valid)
all_skipped.extend(chunk_skipped)
_push_step(r, job_id, "ai",
f" Pages {start_p}{end_p}: {len(chunk_valid)} questions"
f"{f', {len(chunk_skipped)} skipped' if chunk_skipped else ''}. "
f"Total: {len(all_valid_questions)}.")
except Exception as e:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p} failed: {e}. Continuing…")
valid_questions = all_valid_questions
skipped = all_skipped
_push_step(r, job_id, "ai", f"Extraction complete: {len(valid_questions)} valid questions{f', {len(skipped)} skipped' if skipped else ''}.")
if not valid_questions:
# Blaming the model for a document that was never indexed sent
# people to change the model, the prompt and the page range, none
# of which was the problem.
if pages_without_text:
raise ValueError(
"This document has no stored text to read. Its pages are indexed when it is"
" uploaded, and that index is what extraction reads — not the file. Re-process"
" the document and try again.")
raise ValueError(
"The model found no questions with a marked correct answer in these pages.")
# Refresh DB connection — it may have gone stale during long LLM extraction
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
# Re-fetch objects that were bound to the old session
section = db.query(Section).filter(Section.id == section_id).first()
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
# Extract images
_push_step(r, job_id, "images", "Extracting question images…")
file_path = os.path.join(settings.UPLOAD_DIR, document.filename)
page_images = {}
if os.path.exists(file_path):
try:
page_images = pdf_service.extract_all_images(
file_path, document.id, section.start_page, section.end_page
)
except Exception as e:
_push_step(r, job_id, "images", f"Image extraction skipped: {e}")
# A batch, not a quiz. What a model pulled out of a PDF is a proposal:
# it is read, corrected and decided before it is anything, and a
# question id — which comes from a sequence and is never reissued — is
# taken at acceptance rather than at extraction.
from app.models.draft_question import DraftBatch, DraftQuestion
batch = DraftBatch(
title=title,
document_id=document.id,
section_id=section_id,
job_id=job_id,
model_id=model_id,
extraction_mode=extraction_mode,
category_id=question_category_id,
created_by=user_id,
status="open",
)
db.add(batch)
db.flush()
_push_step(r, job_id, "save", f"Saving {len(valid_questions)} drafts for review…")
for pos, q in enumerate(valid_questions):
page_ref = q.get("page_reference")
image_path = None
# Only link an image if the AI flagged the question as having a figure
if q.get("has_figure") and page_ref and page_ref in page_images and page_images[page_ref]:
image_path = page_images[page_ref].pop(0)
if not page_images[page_ref]:
del page_images[page_ref]
db.add(DraftQuestion(
batch_id=batch.id,
position=pos,
question_text=q["question_text"],
question_type=q["question_type"],
options=q.get("options"),
correct_answer=q["correct_answer"],
explanation=q.get("explanation", ""),
page_reference=page_ref,
image_path=image_path,
))
# No embedding here. A vector is for finding a question in the bank,
# and a draft is not in the bank; it is generated when one is accepted.
db.commit()
db.refresh(batch)
_push_step(r, job_id, "done",
f"{len(valid_questions)} drafts ready to review. Nothing is in the bank yet.")
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
r.set(f"extraction:batch_id:{job_id}", str(batch.id), ex=EXPIRE_SECONDS)
return batch.id
except Exception as e:
logger.exception(f"Quiz extraction failed for job {job_id}")
_push_step(r, job_id, "error", f"Extraction failed: {e}")
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
raise
finally:
db.close()
#: What each label means, in the prompt and in the docs, so the three words
#: mean the same thing to the model, to the learner reading a filter, and to
#: whoever disagrees with a label later.
DIFFICULTY_RUBRIC = """easy — one step. The stem names a classic presentation and the answer is
recall: a diagnosis with a pathognomonic finding, a first-line drug, a
standard schedule. A prepared candidate answers without working anything out.
medium — two steps, or one step under noise. The finding has to be interpreted
before it can be used, a distractor is genuinely plausible, or the question
asks for the *next* action rather than the diagnosis.
hard — several steps, or a judgement. An atypical presentation, a decision
where two answers are defensible and one is better, an exception to the rule
the candidate learned, or arithmetic on top of interpretation."""
DIFFICULTY_PROMPT = """You are labelling pediatric board-exam questions by how hard they are to
answer correctly, for a candidate who has studied the material.
Judge the *question*, not the topic. A rare disease with a give-away finding is
easy; a common one where two managements are defensible is not.
""" + DIFFICULTY_RUBRIC + """
Return ONLY a JSON array, one object per question, no prose:
[{"id": 12, "difficulty": "easy"}, ...]
Every id you were given must appear exactly once.
QUESTIONS
"""
#: Small enough that one bad reply costs little and the JSON stays inside the
#: reply limit; large enough that 3,000 questions is a hundred calls and not
#: three thousand.
DIFFICULTY_BATCH = 25
#: Judging a figure against its question needs a model that can see. The job
#: model is passed in rather than read from the admin's `tool` setting, because
#: this is a one-off audit and not a standing capability — it should not
#: quietly become the thing that decides what "the tool model" means.
FIGURE_AUDIT_MODEL = "openrouter-gemini-2.5-flash"
#: Two tries per figure, a second apart. A 502 from the proxy is usually a
#: moment rather than a state.
RETRIES = 3
#: And if it *is* a state, stop. A run that reports every figure unreadable has
#: told you nothing and cost an hour.
GIVE_UP_AFTER = 12
FIGURE_AUDIT_PROMPT = """This figure is attached to the exam question below. Decide whether it belongs
to it.
It belongs if a candidate would need it, or could reasonably use it, to answer
*this* question. It does not belong if it is about some other topic entirely,
which happens when a figure is lifted from the wrong page of a source document.
**The rule that matters, learned by getting it wrong.** Two kinds of figure sit
on these questions and they are not judged the same way.
A *clinical* figure — a photograph, radiograph, ultrasound, ECG, fundoscopy,
otoscopy, a microscopy slide, a specimen — is almost always the right one, and
the connection is often indirect: a tick on a leaf against a July fever, fungal
hyphae against a scaly rash, a recessed chin in the notes of a two-week-old.
Answer "no" for one of these ONLY when it is anatomically or clinically
impossible — a shoulder radiograph on a knee injury. Otherwise "yes", or
"unsure" if you truly cannot tell. Detaching a clinical figure a question needs
is the worst outcome here.
A *non-clinical* figure — a table, a citation, a reference list, a bar chart, a
nomogram, a page of text — belongs only when it is about the same subject as the
question. These are where mis-extraction shows up, and where "no" is usually
right.
Answer ONLY with JSON:
{"belongs": "yes" | "no" | "unsure",
"kind": "clinical" | "table" | "other",
"shows": "one sentence describing the figure"}
QUESTION
"""
@celery_app.task(name="audit_question_figures", bind=True)
def audit_question_figures(self, job_id: str = "", limit: int | None = None,
detach: bool = True, model_id: str | None = None) -> dict:
"""Look at every figure attached to a question and say whether it belongs.
Extraction took figures off the page a question was printed on, which is
usually right and sometimes catastrophically wrong: an HPV vaccination
question was carrying a table of craniofacial reconstruction by age. Nobody
can find those by reading stems, because the mistake is only visible in the
picture.
Every figure is also *described* while it is being looked at, and the
description is kept — most of them said "Figure from question #1206", which
is a filename with extra steps and made the image bank unsearchable.
Detaching is reversible in the sense that matters: the asset stays in the
bank with a note saying what happened, and only the link goes.
"""
import json as _json
from app.models.media import MediaAsset
from app.models.question import Question
from app.models.question_media import QuestionMedia
from app.services import storage_service, thumbnails, vision_service
from app.services.ai_service import chat
r = _redis()
if job_id:
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
model = model_id or FIGURE_AUDIT_MODEL
seen = kept = detached = unsure = unreadable = described = 0
consecutive_failures = 0
last = None
mismatches: list[dict] = []
try:
rows = db.query(Question).filter(
Question.image_path.isnot(None), Question.image_path != "").order_by(Question.id)
questions = rows.limit(limit).all() if limit else rows.all()
total = len(questions)
if job_id:
_push_step(r, job_id, "start", f"{total} figures to look at")
for position, question in enumerate(questions, start=1):
seen += 1
data = None
try:
data = storage_service.load(question.image_path)
except Exception:
data = None
if not data:
unreadable += 1
continue
# 640px is what the reader is shown and plenty to judge a figure by;
# sending the original would be megabytes a question for no gain.
small = thumbnails.render(data, 640) or data
image = vision_service.prepare(small, "image/webp", caption="a figure attached to a question")
if image is None:
unreadable += 1
continue
stem = (question.question_text or "")[:900]
verdict = None
for attempt in range(RETRIES):
try:
raw = (chat(model=model, max_tokens=300, temperature=0, messages=[{
"role": "user", "content": [
vision_service.image_part(image),
{"type": "text", "text": FIGURE_AUDIT_PROMPT + stem},
]}]) or "").strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
raw = raw[:-3] if raw.endswith("```") else raw
verdict = _json.loads(raw.strip())
break
except Exception as exc:
last = exc
# A 502 from the proxy is usually a moment, not a state.
if attempt + 1 < RETRIES:
time.sleep(2 ** attempt)
if verdict is None:
unreadable += 1
consecutive_failures += 1
logger.warning("Figure audit failed for question %s: %s", question.id, last)
# A whole corpus of "unreadable" is not a result, it is an
# outage — and the first run of this marched through 327
# questions reporting nothing while the proxy was down. Stop and
# say so, so the run can be repeated when it is back.
if consecutive_failures >= GIVE_UP_AFTER:
raise RuntimeError(
f"{consecutive_failures} figures in a row could not be read — "
f"the model or the proxy is unavailable. Last error: {last}")
continue
consecutive_failures = 0
belongs = str(verdict.get("belongs", "unsure")).strip().lower()
kind = str(verdict.get("kind", "")).strip().lower()
shows = str(verdict.get("shows", "")).strip()[:600]
# The prompt says it and this enforces it: a clinical figure is
# never detached on the model's say-so alone. Sixteen good ones went
# in the first run — a tick on a leaf, fungal hyphae, an ECG on a
# tachypnoeic neonate — because a judgement about relevance was
# allowed to act on a photograph. It becomes a flag for a person.
if belongs == "no" and kind == "clinical":
belongs = "unsure"
# The description is worth keeping whatever the verdict: "Figure
# from question #1206" is a filename with extra steps.
asset = db.query(MediaAsset).filter(MediaAsset.path == question.image_path).first()
if asset and shows:
placeholder = not asset.alt_text or asset.alt_text.lower().startswith("figure from question")
if placeholder:
asset.alt_text = shows
described += 1
if not asset.caption:
asset.caption = shows
if belongs == "no":
mismatches.append({"question_id": question.id, "path": question.image_path,
"shows": shows, "stem": stem[:160], "kind": kind,
"verdict": "no"})
if detach:
question.image_path = None
db.query(QuestionMedia).filter(
QuestionMedia.question_id == question.id).delete(synchronize_session=False)
if asset:
note = f"Detached from question #{question.id}: the figure shows {shows}"
asset.caption = (asset.caption or shows)
asset.alt_text = asset.alt_text or note
detached += 1
else:
kept += 1
elif belongs == "unsure":
unsure += 1
# Named, not just counted: "2 unsure" is a number nobody can
# act on. These stay attached and go on the list for a person.
mismatches.append({"question_id": question.id, "path": question.image_path,
"shows": shows, "stem": stem[:160], "kind": kind,
"verdict": "unsure"})
else:
kept += 1
db.commit()
if job_id and position % 10 == 0:
_push_step(r, job_id, "batch",
f"{position} of {total} · {detached} detached · {unsure} unsure")
result = {"looked_at": seen, "kept": kept, "detached": detached, "unsure": unsure,
"unreadable": unreadable, "described": described,
"mismatches": mismatches[:60]}
if job_id:
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done",
f"{detached} figures detached, {unsure} to check by hand")
return result
except Exception as exc:
logger.warning("Figure audit failed: %s", exc)
if job_id:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
raise
finally:
db.close()
@celery_app.task(name="classify_question_difficulty", bind=True)
def classify_question_difficulty(self, job_id: str = "", limit: int | None = None,
relabel: bool = False) -> dict:
"""Give every question a difficulty, in batches, resumably.
The column existed and was NULL on all 2,924 rows, which made the filter in
the test builder a control that could only ever empty the bank, and made
"move the session along the difficulty range" impossible to build. Nothing
was ever going to write it by hand.
Resumable by construction: only rows with no difficulty are asked about
unless `relabel` is set, so a run that dies halfway is continued by running
it again. Each batch is committed on its own — a failure late in a long run
keeps everything the earlier batches decided.
"""
from app.models.question import Question
from app.services.ai_service import get_model_for_task, chat
r = _redis()
if job_id:
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
labelled, failed_batches = 0, 0
try:
model_id, api_key = get_model_for_task(db, "keyword")
query = db.query(Question).order_by(Question.id)
if not relabel:
query = query.filter(or_(Question.difficulty.is_(None), Question.difficulty == ""))
rows = query.limit(limit).all() if limit else query.all()
total = len(rows)
if job_id:
_push_step(r, job_id, "start", f"{total} questions to label")
for start in range(0, total, DIFFICULTY_BATCH):
batch = rows[start:start + DIFFICULTY_BATCH]
payload = []
for question in batch:
options = question.options if isinstance(question.options, list) else []
payload.append({
"id": question.id,
# Trimmed: the shape of the reasoning is in the first
# paragraph and the options, and a full vignette times
# twenty-five is a reply nobody needs to pay for.
"stem": (question.question_text or "")[:700],
"options": [str(option)[:120] for option in options[:6]],
"answer": (question.correct_answer or "")[:120],
})
prompt = DIFFICULTY_PROMPT + json.dumps(payload, ensure_ascii=False)
try:
raw = (chat(model=model_id, messages=[{"role": "user", "content": prompt}],
max_tokens=1500, temperature=0, api_key=api_key) or "").strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
raw = raw[:-3] if raw.endswith("```") else raw
verdicts = json.loads(raw.strip())
except Exception as exc:
failed_batches += 1
logger.warning("Difficulty batch at %s failed: %s", start, exc)
if job_id:
_push_step(r, job_id, "warn", f"A batch of {len(batch)} could not be read; skipped")
continue
by_id = {question.id: question for question in batch}
for verdict in verdicts if isinstance(verdicts, list) else []:
question = by_id.get(verdict.get("id"))
level = str(verdict.get("difficulty", "")).strip().lower()
# Anything that is not one of the three words is not a label.
# A model that answers "moderate" has not answered.
if question is None or level not in ("easy", "medium", "hard"):
continue
question.difficulty = level
labelled += 1
db.commit()
if job_id:
_push_step(r, job_id, "batch",
f"{min(start + DIFFICULTY_BATCH, total)} of {total} · {labelled} labelled")
if job_id:
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"{labelled} questions labelled")
return {"labelled": labelled, "considered": total, "failed_batches": failed_batches}
except Exception as exc:
logger.warning("Difficulty run failed: %s", exc)
if job_id:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
raise
finally:
db.close()
@celery_app.task(name="apply_topic_claims")
def apply_topic_claims() -> dict:
"""Re-apply every article's topic claim. The safety net, not the mechanism.
A claim is applied when it is staked and again whenever a question is filed
into the category, which covers the two ways it normally changes. Both can
be bypassed — a bulk update written in SQL, an import, an article restored
from the trash — and a claim that is true should stay true without anybody
remembering to press anything. Normally finds nothing.
"""
db = SessionLocal()
try:
from app.services import topic_claims
result = topic_claims.sweep(db)
if result["links_added"]:
logger.info("Topic claims: %s links added across %s claims",
result["links_added"], result["claims"])
return result
finally:
db.close()
@celery_app.task(name="retry_missing_embeddings")
def retry_missing_embeddings(batch: int = 200) -> dict:
"""Backfill questions that have no usable vector.
Embedding at creation time is best effort: if the encoder is briefly
unavailable the question is still saved, and without this it would stay
invisible to semantic search forever. Runs on a schedule and normally finds
nothing. Also catches rows left by an embedding-model change.
"""
db = SessionLocal()
try:
from app.services import embedding_service
active = embedding_service._get_embedding_model()
pending_total, embedded_total = 0, 0
# Every embeddable corpus, so an article or card is not left behind.
for kind, model in embedding_service.embeddable_models().items():
pending = (
db.query(model)
.filter(
(model.embedding.is_(None))
| (model.embedding_model.is_(None))
| (model.embedding_model != active)
)
.limit(batch)
.all()
)
pending_total += len(pending)
for row in pending:
try:
if embedding_service.embed_record(row, kind):
embedded_total += 1
except Exception:
logger.warning("Retry embedding failed for %s %s", kind, row.id, exc_info=True)
# And the other direction. A section row survives its article being
# deleted or unpublished only if something removes it, and nothing did:
# the index is rebuilt when an article is *saved*, so an article that is
# never saved again keeps its rows forever. Half-written prose then
# stays in search results and in the shortlist the assistant answers
# from, long after a reader could open the page it came from.
dropped = _drop_unreachable_sections(db)
if embedded_total or dropped:
db.commit()
logger.info("Backfilled %s embeddings (%s pending), dropped %s stale section rows",
embedded_total, pending_total, dropped)
return {"pending": pending_total, "embedded": embedded_total,
"dropped": dropped, "model": active}
finally:
db.close()
def _drop_unreachable_sections(db) -> int:
"""Remove index rows whose article is gone or no longer published."""
from sqlalchemy import select
from app.models.article import Article, ArticleSectionIndex
reachable = select(Article.id).where(Article.status == "published")
return (db.query(ArticleSectionIndex)
.filter(~ArticleSectionIndex.article_id.in_(reachable))
.delete(synchronize_session=False))
@celery_app.task(name="regenerate_embeddings", bind=True)
def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = True):
"""Re-embed questions with the current model.
`stale_only` (the default) covers exactly what breaks semantic search: rows
with no vector, and rows whose vector came from a different model and so sits
in an incomparable space. Pass False to rebuild the whole bank.
"""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
r.set(f"extraction:job_title:{job_id}", "Regenerate Embeddings", ex=EXPIRE_SECONDS)
r.lpush(f"extraction:user_jobs:{user_id}", job_id)
r.expire(f"extraction:user_jobs:{user_id}", 86400)
db = SessionLocal()
try:
from app.models.question import Question
from app.services import embedding_service
active = embedding_service._get_embedding_model()
pending = []
for kind, model in embedding_service.embeddable_models().items():
query = db.query(model)
if stale_only:
query = query.filter(
(model.embedding.is_(None))
| (model.embedding_model.is_(None))
| (model.embedding_model != active)
)
pending.extend((kind, row) for row in query.all())
total = len(pending)
scope = "missing or stale" if stale_only else "all"
_push_step(r, job_id, "start", f"Regenerating embeddings for {total} {scope} records…")
ok = 0
for i, (kind, row) in enumerate(pending):
try:
if embedding_service.embed_record(row, kind):
ok += 1
if (i + 1) % 50 == 0:
db.commit()
_push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)")
except Exception as e:
logger.warning(f"Embedding failed for {kind} {row.id}: {e}")
db.commit()
_push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.")
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
except Exception as e:
logger.exception(f"Embedding regeneration failed for job {job_id}")
_push_step(r, job_id, "error", f"Failed: {e}")
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
raise
finally:
db.close()
@celery_app.task(name="generate_flashcard_deck", bind=True)
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
title: str, model_id: str | None = None):
"""Generate flashcards from a document section using AI."""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.services import vector_service
from app.services import extraction_modes
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Section not found")
return
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
from app.services.ai_service import get_model_for_task
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
if model_id:
ai_model_id = model_id
total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
all_cards = []
if total_pages <= CHUNK_PAGES:
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
if content:
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}{section.end_page}")
cards = extraction_modes.generate_flashcards(
content, f"{section.start_page}{section.end_page}",
section.start_page, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
else:
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
for chunk_idx in range(1, n_chunks + 1):
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
if not content or len(content.strip()) < 100:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
continue
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
cards = extraction_modes.generate_flashcards(
content, f"{start_p}{end_p}", start_p, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
if not all_cards:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "No flashcards could be generated")
return
# Refresh DB connection for save phase
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
from app.models.flashcard import FlashcardDeck, Flashcard
deck = FlashcardDeck(
title=title,
section_id=section_id,
user_id=user_id,
card_count=len(all_cards),
)
db.add(deck)
db.flush()
for c in all_cards:
card = Flashcard(
deck_id=deck.id,
front=c["front"],
back=c["back"],
page_reference=c.get("page_reference"),
)
db.add(card)
db.commit()
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
except Exception as e:
logger.exception(f"Flashcard generation failed: {e}")
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
#: The three readings of a topic the reader offers, and what each one is for.
#: Written into the prompt because a model that is not told about them writes
#: one article and the other two tabs stay empty — which is what happened to
#: every generated article until now.
#: Room for the whole thing. An article with a long view, a high-yield view and
#: a clinical one runs well past four thousand tokens, and a reply that stops
#: mid-string is not a JSON document.
ARTICLE_MAX_TOKENS = 16000
class ArticleDraftError(RuntimeError):
"""A refusal with a sentence the educator can act on."""
def _why(exc: Exception) -> str:
"""One sentence a person can act on, out of whatever the provider said.
A proxy answers a missing model with three hundred characters of JSON. The
part that matters is which model, and that it does not exist — everything
else belongs in the log.
"""
text = str(exc)
found = re.search(r"Invalid model name passed in model=([\w.:-]+)", text)
if found:
return (f"The model configured for this job — {found.group(1)} — is not on "
"the AI proxy any more. Pick another in Admin → Models.")
if "429" in text or "rate limit" in text.lower():
return "The AI provider is rate-limiting us. Try again in a minute."
if "timed out" in text.lower() or "timeout" in text.lower():
return "The model took too long to answer."
return text[:160]
def _draft_grounding(r, job_id: str, topic: str, use_library: bool, use_pubmed: bool):
"""Source material for a draft, and the references that come with it.
Both lookups are optional, both are best-effort, and neither can fail the
draft: an educator who ticks a box and gets nothing is told so on the
progress line and still gets their article.
Returned as a block to append to the prompt, so the prompt itself — which
is working — does not change shape when a source is added or removed.
"""
from app.services import clinical_corpus, pubmed
blocks: list[str] = []
references: list[str] = []
if use_library:
_push_step(r, job_id, "ai", "Reading the clinical library…")
found = clinical_corpus.search(topic)
if found["results"]:
blocks.append(
"Excerpts from this institution's clinical library, for you to write "
"from. Use them for the facts, the structure and the emphasis, and "
"write the article in your own words — do not copy sentences out of "
"them. They are reference material, not a draft.\n\n"
+ clinical_corpus.for_prompt(found["results"]))
_push_step(r, job_id, "ai",
f"{len(found['results'])} excerpts from the library.")
else:
_push_step(r, job_id, "ai", f"Library: {found['reason']}.")
if use_pubmed:
_push_step(r, job_id, "ai", "Searching PubMed…")
found = pubmed.search(topic)
if found["results"]:
blocks.append(
"Published literature on this topic, found on PubMed. Where you use "
"one, cite it in the text as (Author, year) and nothing more — the "
"reference list is written for you from these records, so do not "
"invent entries and do not add any of your own.\n\n"
+ pubmed.for_prompt(found["results"]))
references = pubmed.as_references(found["results"])
_push_step(r, job_id, "ai",
f"{len(found['results'])} papers, searched as \"{found.get('query') or topic}\".")
else:
_push_step(r, job_id, "ai", f"PubMed: {found['reason']}.")
return ("\n\n".join(blocks), references)
ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform.
Topic: {topic}
{instructions}
{existing}Every section belongs to one of three readings of the topic, given as "variant":
- "long": the full article. Pathophysiology, presentation, workup, management, complications — whatever the topic needs. This is the body of the work.
- "short": the high-yield revision view. It must be 2 to 4 SEPARATE sections, each one under 600 characters and each a list of single-line bullets of at most 20 words. A bullet that runs to a sentence with subclauses is a long section in the wrong place. It is not a summary of the long article's structure; it is what a candidate must know.
- "clinical": what to do at the bedside, if and only if the topic has a bedside. Assessment, immediate management, when to escalate, disposition. Omit it entirely for a topic that is not acted on clinically.
Return ONLY strict JSON with this exact shape:
{{"title": "...", "slug": "lowercase-hyphenated", "summary": "1-2 sentences", "content": "introduction markdown", "sections": [{{"id": "32 lowercase hex chars", "slug": "lowercase-hyphenated", "title": "...", "variant": "long", "content": "markdown"}}]}}
Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; 3-8 long sections plus 2-4 short ones, each with a stable unique id; every short section must contain at least one and at most three highlighted facts, written by wrapping the words in ==double equals== — for example "- Type II is the ==most common== Salter-Harris fracture" — highlighting the fact itself and never a whole bullet; do not categorise the article or link it to questions — an educator does that; keep any [[123|cross-reference]] already in the text exactly as written and never invent a new one, because the number points at a real article and a guessed one points at nothing; do not mention these instructions."""
@celery_app.task(name="generate_article_draft", bind=True)
def generate_article_draft(self, job_id: str, user_id: int, topic: str,
instructions: str = "", article_id: int | None = None,
model_id: str | None = None, use_library: bool = False,
use_pubmed: bool = False):
"""Create or refine an educator article draft; never publishes."""
import re
import uuid
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.article import Article
from app.services import article_service
from app.services.ai_service import get_model_for_task, chat
from app.config import settings
existing = db.get(Article, article_id) if article_id else None
if article_id and not existing:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Article not found")
return
ai_model_id, ai_api_key = get_model_for_task(db, "article")
if model_id:
ai_model_id = model_id
_push_step(r, job_id, "ai", "Drafting article…")
existing_block = ""
if existing:
# The variant travels with the section. Without it the model was
# shown a flat list of headings and had to guess which readings
# they belonged to all over again — so refining an article could
# quietly turn its bedside section into part of the long read, and
# the high-yield view into prose.
# The id travels too, and must come back. A refine replaces the
# whole section list, so a model that invents fresh ids silently
# breaks every `[[95#id]]` pointing into this article — from
# another article, from a question's key point, from a study plan's
# reading list. Keeping a section means keeping its id.
sections_text = "\n\n".join(
f"## [{s.get('variant', 'long')}] [id:{s.get('id', '')}] {s.get('title', 'Section')}\n"
f"{s.get('content', '')}"
for s in (existing.sections or []))
existing_block = (f"Existing draft to refine (preserve and improve its content; the "
f"bracketed word before each heading is that section's variant and "
f"must be kept, and [id:...] is that section's id which must be "
f"returned unchanged for every section you keep — invent an id only "
f"for a section you are adding):\n"
f"Summary: {existing.summary or ''}\nIntro: {existing.content or ''}\n"
f"{sections_text}\n\n")
prompt = ARTICLE_DRAFT_PROMPT.format(
topic=topic,
instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "",
existing=existing_block,
)
# Anything the educator asked us to draw on, added after the prompt
# rather than woven into it: a draft with nothing to draw on is exactly
# the draft that has been working, and this is the only way to keep
# that true as sources are added.
grounding, references = _draft_grounding(r, job_id, topic, use_library, use_pubmed)
if grounding:
prompt = f"{prompt}\n\n{grounding}"
# 4,000 was the cap, and it is what broke this: the prompt asks for a
# full article *plus* a high-yield view plus a clinical one, the reply
# ran past the ceiling, and `json.loads` failed on a string the model
# never got to close — reported to the educator as "the model may need
# an 'article' configuration", which was nothing to do with it.
raw = chat(
model=ai_model_id, messages=[{"role": "user", "content": prompt}],
max_tokens=ARTICLE_MAX_TOKENS, temperature=0.4, api_key=ai_api_key).strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:]
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as broken:
# A truncated reply is a different problem from a badly configured
# one, and telling an educator to check their model settings when
# the model worked fine is how an afternoon is wasted.
truncated = not raw.rstrip().endswith("}")
raise ArticleDraftError(
"The model's reply was cut off before it finished the article. "
"Try a narrower topic, or ask for fewer sections in the instructions."
if truncated else
f"The model did not return usable JSON ({broken})."
) from broken
title = str(data.get("title", topic)).strip()[:300]
slug = re.sub(r"[^a-z0-9]+", "-", str(data.get("slug", topic)).strip().lower()).strip("-")[:120] or "topic"
sections = []
for section in data.get("sections", []):
section_id = str(section.get("id") or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]{32}", section_id):
section_id = uuid.uuid4().hex
# An unknown or missing variant is the full article, which is what
# every section written before the model was told about the three
# readings already is.
variant = str(section.get("variant") or "long").strip().lower()
if variant not in ("short", "long", "clinical"):
variant = "long"
sections.append({
"id": section_id,
"slug": re.sub(r"[^a-z0-9]+", "-", str(section.get("slug", "section")).strip().lower()).strip("-")[:120] or "section",
"title": str(section.get("title", "Section")).strip()[:300] or "Section",
"variant": variant,
"content": str(section.get("content", "")),
})
if existing:
existing.title, existing.slug, existing.summary = title, slug, str(data.get("summary", "") or "")[:2000]
existing.content, existing.sections = str(data.get("content", "") or ""), sections
article = existing
else:
base_slug = slug
n = 2
while db.query(Article.id).filter(Article.slug == slug).first():
slug = f"{base_slug}-{n}"
n += 1
# Stamped with the model that wrote it. The column existed and
# nothing set it, so an AI draft was indistinguishable from one a
# person typed — and the editorial queue that lists generated
# drafts never showed a single one of them.
article = Article(slug=slug, title=title, summary=str(data.get("summary", "") or "")[:2000],
content=str(data.get("content", "") or ""), sections=sections,
user_id=None, status="draft",
generated_by=str(ai_model_id or "ai")[:80])
db.add(article)
# The reference list is written from the records rather than by the
# model, which is the whole reason for searching PubMed rather than
# asking it what the literature says: every line here is a paper that
# exists, with a PMID somebody can look up.
if references:
kept = list(article.references_json or [])
# By PMID, not by equality: a refined draft searches PubMed again
# and the same paper comes back with the same id, so comparing
# whole records would stack duplicates every run.
have = {r.get("pmid") for r in kept if isinstance(r, dict) and r.get("pmid")}
for record in references:
if record["pmid"] not in have:
kept.append(record)
have.add(record["pmid"])
article.references_json = kept
db.commit()
# A draft that is not indexed is a draft nobody can find. Every writer of
# `Article.sections` has to do this; the ones that did not left 323
# articles with no section rows and a vector built from the title alone.
article_service.reindex(db, article)
# The id, so whoever asked for the draft can be taken straight to it.
r.set(f"extraction:article:{job_id}", str(article.id), ex=EXPIRE_SECONDS)
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"Draft saved: {title}")
except ArticleDraftError as refusal:
logger.warning("Article draft job %s: %s", job_id, refusal)
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(refusal)[:300], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", str(refusal)[:300])
except Exception as exc:
logger.warning("Article draft job %s failed: %s", job_id, exc)
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", f"Drafting failed: {str(exc)[:200]}")
finally:
db.close()
@celery_app.task(name="generate_article_cards", bind=True)
def generate_article_cards(self, job_id: str, user_id: int, article_id: int,
model_id: str | None = None):
"""Generate cards from an article into an unshared educator deck; links stay private until shared."""
r = _redis()
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
db = SessionLocal()
try:
from app.models.article import Article
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.services import extraction_modes
from app.services.ai_service import get_model_for_task
article = db.get(Article, article_id)
if not article:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "Article not found")
return
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
if model_id:
ai_model_id = model_id
_push_step(r, job_id, "ai", f"Generating cards from {article.title}")
content = "\n\n".join(filter(None, [
article.title, article.summary, article.content,
*[f"## {s['title']}\n{s['content']}" for s in (article.sections or [])],
]))
# As many cards as the article has substance for. Fifteen is the
# per-chunk default, and an article is one chunk however long it is —
# so a ten-section piece and a two-paragraph stub asked for the same
# fifteen. Roughly a card per 150 words, floored so a short article
# still makes a usable deck and capped so one call stays inside the
# model's output.
words = len(content.split())
wanted = max(12, min(30, round(words / 150)))
cards = extraction_modes.generate_flashcards(
content, "article", None, ai_model_id, ai_api_key, n=wanted)
if not cards:
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "error", "No cards could be generated")
return
deck = db.query(FlashcardDeck).filter(
FlashcardDeck.title == f"Cards: {article.title}",
FlashcardDeck.user_id == user_id,
FlashcardDeck.deleted_at.is_(None),
).first()
if not deck:
# Filed where the article is filed. A deck written from an
# article belongs to the same topic, and asking somebody to
# choose the category again is asking them to repeat a fact
# the system already knows.
deck = FlashcardDeck(title=f"Cards: {article.title}", user_id=None,
category_id=article.category_id, card_count=0, is_shared=0)
db.add(deck)
db.flush()
new_cards = []
for card in cards:
item = Flashcard(deck_id=deck.id, front=card["front"], back=card["back"],
page_reference=card.get("page_reference"))
db.add(item)
new_cards.append(item)
db.flush()
for item in new_cards:
db.add(FlashcardArticleLink(flashcard_id=item.id, article_id=article.id))
deck.card_count = db.query(Flashcard).filter(Flashcard.deck_id == deck.id).count()
db.commit()
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"{len(cards)} cards saved to private deck {deck.title}")
except Exception as exc:
logger.warning("Article cards job %s failed: %s", job_id, exc)
db.rollback()
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS)
# The reason, on the line somebody actually reads. "Card generation
# failed." was all the jobs panel showed for a fortnight while the
# answer — a model the proxy had dropped — sat in a field nothing
# displayed.
_push_step(r, job_id, "error", f"Card generation failed. {_why(exc)}")
finally:
db.close()