pdf-quiz-generator/backend/app/services/ai_mode_service.py
Daniel 031de53034 feat: rerank what a learner is shown, with Cohere through the proxy
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.

The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.

It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.

Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.

And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.

Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.

The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.

Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.

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

446 lines
20 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.

"""Retrieval and citation handling for AI Mode.
The safety property, stated plainly: **retrieval decides what may be cited, and
the server enforces it.** The model is handed a numbered shortlist and told to
cite from it by id. Afterwards every citation it wrote is checked against that
shortlist and anything else is deleted. A citation the model invented cannot
survive, because surviving is not a thing the model gets to decide.
This is the same discipline as the article page not printing answers: a property
the system holds, not one the model is trusted to respect.
Two kinds of link exist and are treated differently. *Curated* links are rows an
educator created — a question tied to an article section, a card to a question.
They are assertions, so they are trusted, and a retrieved row that carries one to
another retrieved row is boosted: two things an educator already tied together
answering the same query is evidence, not coincidence. *Retrieved* links are
ranked guesses computed per query and stored nowhere.
The shortlist is chosen twice. Each corpus offers twice as many candidates as it
can have places for, and a cross-encoder reads all of them against the query and
decides which survive and in what order. Unlike everywhere else on the site,
where reranking is a strict permutation of a page of results, here it selects —
because here the shortlist is not a page somebody skims past but the entire
evidence an answer may be built from, and a source that does not make it is one
the model cannot lean on. If no reranker answers, each corpus keeps the six it
elected, in the order it elected them, exactly as before there was one.
"""
import logging
import re
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.question import Question
from app.models.user import User
from app.services import rerank_service
from app.services.quiz_builder import bank_query, exam_scope_predicate
from app.services.search_service import hybrid_ids, top_similarity
logger = logging.getLogger(__name__)
# How many of each kind retrieval offers the model. Small on purpose: a
# shortlist the model can hold is worth more than a corpus it skims.
PER_KIND = 6
# How many of each kind the cross-encoder gets to choose from. Handing it only
# the six a corpus already elected would leave the membership of the shortlist
# entirely to rankers that never read the query against the document; twelve
# lets a rank-nine section reach the answer, and keeps the union of four corpora
# inside a single rerank call.
CANDIDATES_PER_KIND = PER_KIND * 2
MAX_SOURCES = 14
# An excerpt long enough to answer from, short enough that fourteen of them fit.
EXCERPT_CHARS = 700
# A curated tie between two retrieved rows is evidence an educator left behind.
CURATED_BOOST = 0.5
CITATION_RE = re.compile(r"\[\[(article|section|question|card):([A-Za-z0-9#_-]+)\]\]")
def _clean(value: str | None, limit: int = EXCERPT_CHARS) -> str:
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "")
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit] + ("" if len(text) > limit else "")
def _articles(db: Session, user: User, query: str) -> list[dict]:
ranked, _ = hybrid_ids(db, query, "article", limit=PER_KIND * 3)
if not ranked:
return []
rows = db.query(Article).filter(Article.id.in_(ranked)).all()
if not user.is_moderator:
rows = [a for a in rows if a.status == "published" or a.user_id == user.id]
order = {rid: i for i, rid in enumerate(ranked)}
rows.sort(key=lambda a: order.get(a.id, len(order)))
return [{
"kind": "article", "ref": str(a.id), "id": a.id,
"title": a.title,
"text": _clean(a.summary or a.content),
"score": 1.0 / (1 + order.get(a.id, 0)),
} for a in rows[:CANDIDATES_PER_KIND]]
def _sections(db: Session, user: User, query: str) -> list[dict]:
ranked, _ = hybrid_ids(db, query, "article_section", limit=PER_KIND * 3)
if not ranked:
return []
rows = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.id.in_(ranked)).all()
if not rows:
return []
articles = {a.id: a for a in db.query(Article).filter(
Article.id.in_({r.article_id for r in rows})).all()}
order = {rid: i for i, rid in enumerate(ranked)}
rows.sort(key=lambda r: order.get(r.id, len(order)))
out = []
for row in rows[:CANDIDATES_PER_KIND]:
article = articles.get(row.article_id)
if not article:
continue
if article.status != "published" and not user.is_moderator and article.user_id != user.id:
continue
out.append({
"kind": "section", "ref": f"{article.id}#{row.section_id}",
"id": article.id, "section_id": row.section_id,
"title": f"{article.title} {row.title or 'section'}",
"text": _clean(row.content),
"score": 1.0 / (1 + order.get(row.id, 0)),
})
return out
def _questions(db: Session, user: User, query: str) -> list[dict]:
ranked, _ = hybrid_ids(db, query, "question", limit=PER_KIND * 3)
if not ranked:
return []
q = bank_query(db, user).filter(Question.id.in_(ranked))
scope = exam_scope_predicate(db, user)
if scope is not None:
q = q.filter(scope)
order = {rid: i for i, rid in enumerate(ranked)}
rows = sorted(q.all(), key=lambda r: order.get(r.id, len(order)))
return [{
"kind": "question", "ref": str(row.id), "id": row.id,
# The opening of the stem, not the row's number. "Question #2320" tells
# a reader nothing about whether it is worth following, and the list of
# sources under an answer is exactly where that has to be decidable.
"title": _clean(row.question_text, 90) or f"Question #{row.id}",
# The stem only. An answer belongs to the quiz runner, and a chat that
# printed it would hand away the practice it is meant to prepare for.
"text": _clean(row.question_text, 320),
"score": 1.0 / (1 + order.get(row.id, 0)),
} for row in rows[:CANDIDATES_PER_KIND]]
def _cards(db: Session, user: User, query: str) -> list[dict]:
ranked, _ = hybrid_ids(db, query, "flashcard", limit=PER_KIND * 3)
if not ranked:
return []
own = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()]
if not own:
return []
rows = db.query(Flashcard).filter(
Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all()
order = {rid: i for i, rid in enumerate(ranked)}
rows.sort(key=lambda r: order.get(r.id, len(order)))
return [{
"kind": "card", "ref": str(row.id), "id": row.id,
"title": _clean(row.front, 90),
"text": _clean(row.back, 260),
"score": 1.0 / (1 + order.get(row.id, 0)),
} for row in rows[:CANDIDATES_PER_KIND]]
def _apply_curated_boost(db: Session, sources: list[dict]) -> None:
"""Lift a source that an educator tied to another source in this shortlist.
Two rows an educator already linked, both surfacing for the same question, is
a stronger signal than either ranking alone — that is what a curated link is
for. Nothing is stored; the boost lives only in this ordering.
"""
article_ids = {s["id"] for s in sources if s["kind"] in ("article", "section")}
question_ids = {s["id"] for s in sources if s["kind"] == "question"}
if not article_ids or not question_ids:
return
pairs = db.query(QuestionArticleLink).filter(
QuestionArticleLink.article_id.in_(article_ids),
QuestionArticleLink.question_id.in_(question_ids)).all()
if not pairs:
return
linked_articles = {p.article_id for p in pairs}
linked_questions = {p.question_id for p in pairs}
for source in sources:
if source["kind"] in ("article", "section") and source["id"] in linked_articles:
source["score"] += CURATED_BOOST
source["curated"] = True
elif source["kind"] == "question" and source["id"] in linked_questions:
source["score"] += CURATED_BOOST
source["curated"] = True
def _cross_encode(query: str, sources: list[dict]) -> None:
"""Rescore the whole shortlist on one scale, in place, if a reranker answers.
The scores the finders assign are `1/(1+rank)` *within their own corpus*, so
the best article, the best section, the best question and the best card all
score 1.0 and the order between them is whatever the sort happened to do.
A cross-encoder is the first thing in this pipeline that can compare a
section against a question, because it reads both against the same query.
The new score is the position, not the raw relevance: 0.75 from one reranker
and 0.78 from another mean nothing to `CURATED_BOOST`, whereas `1/(1+rank)`
keeps a curated pair worth the same few places it has always been worth.
Silence leaves the scores alone. That is the whole degradation story here:
without a reranker the shortlist is exactly what it was before there was one.
"""
if len(sources) < 2:
return
scores = rerank_service.rerank(query, [f"{s['title']}. {s['text']}" for s in sources])
if scores is None:
return
ranking = sorted(range(len(sources)), key=lambda i: (-scores[i], i))
for position, index in enumerate(ranking):
sources[index]["score"] = 1.0 / (1 + position)
def retrieve(db: Session, user: User, query: str) -> list[dict]:
"""The only things the model will be allowed to cite for this message."""
query = (query or "").strip()
if len(query) < 2:
return []
sources: list[dict] = []
for finder in (_sections, _articles, _questions, _cards):
try:
sources.extend(finder(db, user, query))
except Exception:
# One corpus failing narrows the answer; it does not end it.
logger.warning("AI Mode retrieval failed for %s", finder.__name__, exc_info=True)
_cross_encode(query, sources)
_apply_curated_boost(db, sources)
sources.sort(key=lambda s: -s["score"])
# Still no more than `PER_KIND` of anything. A shortlist the cross-encoder
# filled with fourteen sections of one article would score well and read
# like a single source quoted fourteen times, and it would leave the chat
# with no question to send the learner to practise.
kept: list[dict] = []
seen: dict[str, int] = {}
for source in sources:
if seen.get(source["kind"], 0) >= PER_KIND:
continue
seen[source["kind"]] = seen.get(source["kind"], 0) + 1
kept.append(source)
if len(kept) >= MAX_SOURCES:
break
return kept
def sources_block(sources: list[dict]) -> str:
"""The shortlist, as the model sees it."""
lines = []
for source in sources:
marker = f"[[{source['kind']}:{source['ref']}]]"
curated = " (an educator linked this to another source here)" if source.get("curated") else ""
lines.append(f"{marker} {source['title']}{curated}\n{source['text']}")
return "\n\n".join(lines)
# How close the nearest thing in the library has to be before the answer is
# treated as coming from it. Measured against this corpus with the bodies
# embedded, eight clearly on-topic questions and eight clearly off-topic ones:
#
# off-topic 0.339 0.499 (the French revolution … photosynthesis)
# on-topic 0.586 0.740 (what causes croup … posterior urethral valves)
#
# The gap between them is where these sit. They are not the retrieval floor and
# must not be: `SEMANTIC_FLOOR` decides what is worth showing in a list, where a
# weak hit costs a reader one glance. Here it decides whether an answer claims
# to come from the library, and a wrong claim costs them their trust in every
# other answer.
#
# Worth re-measuring when the corpus changes size or subject; the method and
# the full measurement are in docs/retrieval-thresholds.md. Anything else is
# tuning by feel against numbers nobody wrote down.
#
# Deliberately not the cross-encoder's score, though it is the better judge of a
# pair. The question here is "is there anything in this library about this at
# all", and `top_similarity` answers it by scanning every embedded row in two
# corpora through the vector index. A reranker can only score candidates that
# were shortlisted first, so a reranked closeness cannot tell "the library does
# not cover this" from "retrieval had a bad day", and it would put a network hop
# in the path of a decision about what the answer *claims* — the one place where
# a service being down must not change the output. Order is a preference;
# whether an answer says it came from the library is a promise.
STRONG_MATCH = 0.55
ADJACENT_MATCH = 0.50
ROLE = "You are a study assistant for a pediatrics learning platform.\n"
CITE = (
"Cite with the exact marker shown, for example [[article:7]] or "
"[[section:7#abc123]], placed at the end of the sentence it supports. Never "
"write a URL and never cite a marker that is not listed here.\n\n"
"Never reveal the answer to a practice question. You may say what a question "
"is about so the learner can go and attempt it.\n\n"
"Be brief: a few sentences or a short list.\n\n"
)
def closeness(db: Session, query: str) -> float | None:
"""How close the nearest thing in the library is, or None if unmeasurable."""
try:
return top_similarity(db, query)
except Exception:
logger.warning("AI Mode could not measure closeness", exc_info=True)
return None
def answer_mode(similarity: float | None, sources: list[dict]) -> str:
"""Which of the three answers this question gets: sourced, adjacent, or open.
Decided by a number rather than by asking the model to work out which
situation it is in. Classification written as prose in a prompt is the part
that does not work, and it is also the part that makes the prompt long.
An unmeasurable closeness — no vector database, encoder down — is not a low
one. Retrieval still found these rows by other means, and discarding them
because the ruler is missing would silently drop every citation on a
deployment where semantic search happens to be unavailable.
"""
if not sources:
return "open"
if similarity is None or similarity >= STRONG_MATCH:
return "sourced"
return "adjacent" if similarity >= ADJACENT_MATCH else "open"
def build_prompt(sources: list[dict], mode: str = "sourced") -> str:
if mode == "open" or not sources:
# Nothing in the library is close, so the honest answer is to say that
# and then help anyway. Refusing outright was the old behaviour and it
# reads as a broken assistant rather than a careful one; answering as
# though the shortlist supported it would be worse still.
return (
ROLE +
"Nothing in this learner's library covers their question.\n\n"
"Open with one short sentence saying so. Then answer from general "
"knowledge, briefly and plainly. Do not cite anything: there is "
"nothing here to cite, and a marker you invent points nowhere."
)
if mode == "adjacent":
# Something related, nothing direct. Naming the gap is the point: a
# learner told "here is what is closest" can judge the answer, where one
# handed an adjacent source as though it were the answer cannot.
return (
ROLE +
"Nothing in this learner's library covers their question directly. "
"The sources below are the closest things in it.\n\n"
"Open with one short sentence saying that, naming what the closest "
"material is about. Then answer using those sources where they help, "
"citing them, and from general knowledge where they do not — saying "
"which is which.\n\n"
+ CITE +
f"SOURCES\n\n{sources_block(sources)}"
)
return (
ROLE + "\n"
"Answer only from the sources below. They are the learner's own library — "
"if they do not contain the answer, say so rather than filling the gap from "
"your own knowledge, which the learner cannot check against anything.\n\n"
+ CITE +
f"SOURCES\n\n{sources_block(sources)}"
)
def enforce_citations(reply: str, sources: list[dict]) -> tuple[str, list[dict]]:
"""Drop every citation that was not retrieved, and report the ones that stand.
This is the step that makes a hallucinated citation impossible rather than
unlikely. The model can write whatever marker it likes; only markers in the
shortlist survive contact with this function.
"""
allowed = {f"{s['kind']}:{s['ref']}": s for s in sources}
used: dict[str, dict] = {}
def replace(match: re.Match) -> str:
key = f"{match.group(1)}:{match.group(2)}"
source = allowed.get(key)
if source is None:
return "" # Invented, or pointing at something this learner may not see.
used[key] = source
return match.group(0)
cleaned = CITATION_RE.sub(replace, reply)
# Deleting a marker can leave a double space or a space before a full stop.
cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
cleaned = re.sub(r"\s+([.,;:!?])", r"\1", cleaned).strip()
citations = [{
"marker": f"[[{s['kind']}:{s['ref']}]]",
"kind": s["kind"], "id": s["id"],
"section_id": s.get("section_id"),
"title": s["title"],
"curated": bool(s.get("curated")),
} for s in used.values()]
return cleaned, citations
#: A session assembled from a conversation. Enough to be worth sitting, few
#: enough that it follows from what was just discussed rather than becoming a
#: general exam on the topic.
PRACTICE_MAX = 20
def practice_ids(db: Session, user: User, citations, question: str) -> list[int]:
"""Which questions a chat turn should send a learner to practise.
Three sources, in the order they deserve trust. A question the answer
actually cited is the closest thing to "this is what we were talking
about". An article it cited stands for a topic, so questions filed under
that article's category follow. Retrieval on the learner's own words
catches the rest.
Everything is put through the bank's own visibility rules on the way out —
a chat is not a way to reach questions a learner could not otherwise see.
"""
from app.models.question_category import QuestionCategoryLink
cited_questions = [int(c["id"]) for c in (citations or []) if c.get("kind") == "question"]
article_ids = [int(c["id"]) for c in (citations or []) if c.get("kind") == "article"]
from_articles: list[int] = []
if article_ids:
categories = [cid for (cid,) in db.query(Article.category_id).filter(
Article.id.in_(article_ids), Article.category_id.isnot(None)).all()]
if categories:
direct = db.query(Question.id).filter(
Question.question_category_id.in_(categories)).limit(PRACTICE_MAX * 3).all()
linked = db.query(QuestionCategoryLink.question_id).filter(
QuestionCategoryLink.category_id.in_(categories)).limit(PRACTICE_MAX * 3).all()
from_articles = [qid for (qid,) in [*direct, *linked]]
retrieved: list[int] = []
try:
retrieved, _ = hybrid_ids(db, question or "", "question", limit=PRACTICE_MAX * 2)
except Exception:
logger.warning("Practice retrieval failed", exc_info=True)
ordered: list[int] = []
for group in (cited_questions, from_articles, retrieved):
for qid in group:
if qid not in ordered:
ordered.append(qid)
if not ordered:
return []
allowed = bank_query(db, user).filter(Question.id.in_(ordered))
scope = exam_scope_predicate(db, user)
if scope is not None:
allowed = allowed.filter(scope)
visible = {row.id for row in allowed.all()}
return [qid for qid in ordered if qid in visible][:PRACTICE_MAX]