pdf-quiz-generator/backend/app/services/ai_mode_service.py
Daniel 9beafff0bf feat: AI Mode — a chat that cannot cite what it did not find
The design settled earlier, built as described: retrieval decides what may be
cited, and the server enforces it.

The model is handed a shortlist of at most fourteen sources from the learner's
own library and told to cite them by marker. Afterwards every citation it wrote
is checked against that shortlist and anything else is deleted before it is
stored or shown. A hallucinated citation is not unlikely here, it is impossible
— surviving is not a decision the model gets to make. A URL it invents is not a
citation either: only the marker form counts, so a plausible-looking link stays
in the prose citing nothing.

Retrieval reuses the hybrid search already in place, and each corpus keeps its
own visibility rules — the bank predicate and exam scope for questions, the
draft rule for articles, deck ownership for cards. A question source carries the
stem only: a chat that printed the answer would hand away the practice it exists
to prepare you for.

Curated links do the job they were built for. A retrieved row an educator tied
to another retrieved row is boosted, because two things somebody already linked
surfacing for one query is evidence rather than coincidence. Nothing is stored
for this; the boost lives only in that ordering, and the answer marks those
sources so the reader knows which claim rests on an educator's judgement rather
than on a ranking.

Citations are stored with the answer as filtered, so reopening a thread shows
the links it showed at the time rather than a fresh retrieval that may now rank
differently. In the page the markers become numbers and each number opens its
source; a section citation deep-links into that section.

Two smaller decisions worth naming: a question appears in the thread the moment
you send it and is handed back to the input if the answer fails, because typed
words are not something to lose on a 502; and someone else's thread returns 404
rather than 403, since whether it exists is not your business either.

182 backend, 206 frontend green — 16 of the backend tests are the citation
contract and the retrieval boundary.

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

246 lines
10 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.
"""
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.quiz_builder import bank_query, exam_scope_predicate
from app.services.search_service import hybrid_ids
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
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[: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[: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,
"title": 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[: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[: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 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)
_apply_curated_boost(db, sources)
sources.sort(key=lambda s: -s["score"])
return sources[:MAX_SOURCES]
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)
def build_prompt(sources: list[dict]) -> str:
if not sources:
return (
"You are a study assistant for a pediatrics learning platform.\n"
"Nothing in this learner's library matches their question. Say so plainly "
"in one or two sentences and suggest what they might search for instead. "
"Do not answer from your own knowledge, and do not cite anything."
)
return (
"You are a study assistant for a pediatrics learning platform.\n\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 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"
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