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
This commit is contained in:
Daniel 2026-09-10 15:19:24 +02:00
parent 2d2845fa0a
commit 9beafff0bf
12 changed files with 1129 additions and 5 deletions

View file

@ -0,0 +1,41 @@
"""AI Mode threads and their turns.
Revision ID: a5b6c7d8e9f0
Revises: f4a5b6c7d8e9
"""
import sqlalchemy as sa
from alembic import op
revision = "a5b6c7d8e9f0"
down_revision = "f4a5b6c7d8e9"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"conversations",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("title", sa.String(200), nullable=False, server_default="New chat"),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)
op.create_table(
"conversation_messages",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("conversation_id", sa.Integer,
sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("content", sa.Text, nullable=False),
# What the answer was allowed to cite after filtering, so reopening a
# thread shows the links it showed at the time.
sa.Column("citations", sa.JSON, nullable=False, server_default="[]"),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
def downgrade():
op.drop_table("conversation_messages")
op.drop_table("conversations")

View file

@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
@ -168,7 +168,7 @@ def setup_pgvector():
# Import new models so create_all picks them up # Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
from app.models import flashcard, course # noqa from app.models import flashcard, course # noqa
from app.models import category_grant, exam, media, study_plan # noqa from app.models import category_grant, conversation, exam, media, study_plan # noqa
# Kill stale idle-in-transaction connections from previous killed startups. # Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely. # They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
@ -643,6 +643,7 @@ app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"]) app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"]) app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
app.include_router(search.router, prefix="/api/search", tags=["search"]) app.include_router(search.router, prefix="/api/search", tags=["search"])
app.include_router(ai_mode.router, prefix="/api/ai", tags=["ai-mode"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -0,0 +1,43 @@
from datetime import datetime
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.database import Base
class Conversation(Base):
"""One AI Mode thread, belonging to one learner."""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
title = Column(String(200), nullable=False, default="New chat")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
messages = relationship("ConversationMessage", back_populates="conversation",
cascade="all, delete-orphan",
order_by="ConversationMessage.id")
class ConversationMessage(Base):
"""A turn in a thread.
`citations` holds what the answer was allowed to cite *after* the server
filtered it, so reopening a thread shows the same links it showed at the
time not a fresh retrieval that may now rank differently.
"""
__tablename__ = "conversation_messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False, index=True)
role = Column(String(16), nullable=False) # user | assistant
content = Column(Text, nullable=False)
citations = Column(JSON, nullable=False, default=list)
created_at = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")

View file

@ -0,0 +1,156 @@
"""AI Mode — a chat that can only answer from the learner's own library.
The router is thin on purpose. Everything that matters is in
`ai_mode_service`: retrieval builds the shortlist, the shortlist is the whole
prompt, and every citation the model writes is checked against that shortlist
before anyone sees it.
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.conversation import Conversation, ConversationMessage
from app.models.user import User
from app.services import ai_mode_service
from app.services.ai_service import get_model_for_task
from app.utils.auth import check_rate_limit, get_current_user
router = APIRouter()
log = logging.getLogger(__name__)
DAILY_LIMIT = 60
# How much of the thread goes back to the model. Long enough to follow a
# conversation, short enough that the sources stay the bulk of the prompt.
HISTORY_TURNS = 8
def _own(db: Session, conversation_id: int, user: User) -> Conversation:
conversation = db.get(Conversation, conversation_id)
if not conversation or conversation.user_id != user.id:
# Not "forbidden": whether somebody else's thread exists is not this
# user's business either.
raise HTTPException(404, "Conversation not found")
return conversation
def _message_json(message: ConversationMessage) -> dict:
return {
"id": message.id, "role": message.role, "content": message.content,
"citations": message.citations or [],
"created_at": message.created_at,
}
@router.get("/conversations")
def list_conversations(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
rows = db.query(Conversation).filter(Conversation.user_id == current_user.id).order_by(
Conversation.updated_at.desc(), Conversation.id.desc()).limit(50).all()
return [{"id": c.id, "title": c.title, "updated_at": c.updated_at,
"message_count": len(c.messages)} for c in rows]
@router.post("/conversations", status_code=201)
def create_conversation(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
conversation = Conversation(user_id=current_user.id, title="New chat")
db.add(conversation)
db.commit()
return {"id": conversation.id, "title": conversation.title, "messages": []}
@router.get("/conversations/{conversation_id}")
def get_conversation(conversation_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
conversation = _own(db, conversation_id, current_user)
return {"id": conversation.id, "title": conversation.title,
"messages": [_message_json(m) for m in conversation.messages]}
class ConversationRename(BaseModel):
title: str = Field(min_length=1, max_length=200)
@router.patch("/conversations/{conversation_id}")
def rename_conversation(conversation_id: int, data: ConversationRename,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
conversation = _own(db, conversation_id, current_user)
conversation.title = data.title.strip()
db.commit()
return {"id": conversation.id, "title": conversation.title}
@router.delete("/conversations/{conversation_id}", status_code=204)
def delete_conversation(conversation_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
db.delete(_own(db, conversation_id, current_user))
db.commit()
class AskIn(BaseModel):
message: str = Field(min_length=1, max_length=2000)
@router.post("/conversations/{conversation_id}/messages")
async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Answer from the learner's library, citing only what retrieval found."""
conversation = _own(db, conversation_id, current_user)
question = data.message.strip()
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
check_rate_limit(
key=f"ai_mode_daily:{current_user.id}:{today}",
max_calls=DAILY_LIMIT, window_seconds=86400,
detail=f"You've reached today's AI Mode limit of {DAILY_LIMIT} messages.",
user=current_user,
)
model_id, api_key = get_model_for_task(db, "teach")
if not model_id:
raise HTTPException(503, "No AI model is configured. Ask an admin to set one up.")
sources = ai_mode_service.retrieve(db, current_user, question)
history = [{"role": m.role, "content": m.content}
for m in conversation.messages[-HISTORY_TURNS:]]
messages = [{"role": "system", "content": ai_mode_service.build_prompt(sources)},
*history, {"role": "user", "content": question}]
try:
import litellm
from app.config import settings
from app.services.ai_service import _proxy_model
kwargs = {"model": _proxy_model(model_id), "messages": messages,
"max_tokens": 700, "temperature": 0.3}
if api_key or settings.LITELLM_API_KEY:
kwargs["api_key"] = api_key or settings.LITELLM_API_KEY
if settings.LITELLM_API_BASE:
kwargs["api_base"] = settings.LITELLM_API_BASE
response = await litellm.acompletion(**kwargs)
raw = (response.choices[0].message.content or "").strip()
except Exception:
log.error("AI Mode failed for user %s", current_user.id, exc_info=True)
raise HTTPException(502, "AI Mode is temporarily unavailable. Try again in a moment.")
# The safety step: anything the model cited that retrieval did not find is
# removed here, before it is stored or shown.
reply, citations = ai_mode_service.enforce_citations(raw, sources)
db.add(ConversationMessage(conversation_id=conversation.id, role="user",
content=question, citations=[]))
answer = ConversationMessage(conversation_id=conversation.id, role="assistant",
content=reply, citations=citations)
db.add(answer)
# The first question names the thread; "New chat" ages badly in a rail of them.
if conversation.title == "New chat":
conversation.title = question[:80] + ("" if len(question) > 80 else "")
conversation.updated_at = datetime.utcnow()
db.commit()
db.refresh(answer)
return {"message": _message_json(answer), "title": conversation.title,
"source_count": len(sources)}

View file

@ -0,0 +1,246 @@
"""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

View file

@ -0,0 +1,201 @@
"""AI Mode: retrieval, the citation contract, and thread ownership.
Disposable SQLite; the model itself is stubbed, because what is worth testing
here is not what a model says but what the server does with it. The safety
property an invented citation cannot survive has to hold whatever comes back.
"""
import unittest
from unittest.mock import AsyncMock, patch
import test_quiz_builder as fixtures
from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink
from app.models.conversation import Conversation, ConversationMessage
from app.models.flashcard import Flashcard, FlashcardDeck
from app.models.question import Question
from app.routers import ai_mode
from app.services import ai_mode_service
class CitationContractTests(unittest.TestCase):
"""The step that makes a hallucinated citation impossible rather than rare."""
def sources(self):
return [
{"kind": "article", "ref": "7", "id": 7, "title": "Febrile seizures"},
{"kind": "section", "ref": "7#abc", "id": 7, "section_id": "abc", "title": "Workup"},
]
def test_a_citation_that_was_not_retrieved_is_deleted(self):
reply, citations = ai_mode_service.enforce_citations(
"Fever first [[article:7]]. Then lumbar puncture [[article:99]].", self.sources())
self.assertIn("[[article:7]]", reply)
self.assertNotIn("99", reply)
self.assertEqual([c["id"] for c in citations], [7])
def test_deleting_a_marker_does_not_leave_broken_punctuation(self):
reply, _ = ai_mode_service.enforce_citations(
"This is true [[article:404]].", self.sources())
self.assertEqual(reply, "This is true.")
def test_a_url_the_model_invents_is_not_a_citation(self):
# Only the marker form counts, so a plausible-looking link cannot smuggle
# itself into the citation list.
reply, citations = ai_mode_service.enforce_citations(
"See https://uptodate.com/febrile-seizures for more.", self.sources())
self.assertEqual(citations, [])
self.assertIn("uptodate.com", reply) # left in the prose, cited by nothing
def test_the_same_source_cited_twice_is_listed_once(self):
_, citations = ai_mode_service.enforce_citations(
"One [[article:7]]. Two [[article:7]].", self.sources())
self.assertEqual(len(citations), 1)
def test_a_section_citation_keeps_the_section_it_points_at(self):
_, citations = ai_mode_service.enforce_citations("Here [[section:7#abc]].", self.sources())
self.assertEqual(citations[0]["section_id"], "abc")
def test_with_no_sources_the_model_is_told_to_say_so(self):
prompt = ai_mode_service.build_prompt([])
self.assertIn("Do not answer from your own knowledge", prompt)
# And nothing it writes can be cited anyway.
reply, citations = ai_mode_service.enforce_citations("Anything [[article:1]].", [])
self.assertEqual(citations, [])
self.assertEqual(reply, "Anything.")
class _AiModeBase(unittest.TestCase):
"""Fixtures shared by the route and retrieval cases; holds no tests itself."""
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(ai_mode.router, prefix='/ai')
self.db = self.bank.db
self.db.add(Article(id=7, slug='febrile-seizures', title='Febrile seizures',
summary='A seizure with fever in a young child',
sections=[{"id": "a" * 32, "slug": "workup", "title": "Workup", "content": "LP"}],
status='published', user_id=3))
self.db.add(ArticleSectionIndex(id=1, article_id=7, section_id='a' * 32,
title='Workup', content='Lumbar puncture in a febrile infant'))
self.db.add(FlashcardDeck(id=1, title='Mine', user_id=1))
self.db.flush()
self.db.add(Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'))
self.db.query(Question).filter(Question.id == 1).update(
{"question_text": "A child with a febrile seizure lasting two minutes"})
self.db.commit()
self.bank.user = self.bank.owner
def tearDown(self):
self.bank.tearDown()
def reply_with(self, text):
"""Stub the model, so the test is about the server's half of the contract."""
response = type("R", (), {"choices": [type("C", (), {
"message": type("M", (), {"content": text})()})()]})()
return patch("litellm.acompletion", new=AsyncMock(return_value=response))
def ask(self, message, conversation_id=None):
if conversation_id is None:
conversation_id = self.client.post('/ai/conversations').json()['id']
return conversation_id, self.client.post(
f'/ai/conversations/{conversation_id}/messages', json={'message': message})
class AiModeRouteTests(_AiModeBase):
def test_an_answer_keeps_only_the_citations_retrieval_found(self):
with self.reply_with("Yes [[article:7]], and also [[article:12345]]."):
_, response = self.ask('febrile seizure')
self.assertEqual(response.status_code, 200, response.text)
body = response.json()['message']
self.assertNotIn('12345', body['content'])
self.assertEqual([c['id'] for c in body['citations']], [7])
def test_the_first_question_names_the_thread(self):
with self.reply_with("An answer."):
conversation_id, response = self.ask('What causes febrile seizures?')
self.assertEqual(response.json()['title'], 'What causes febrile seizures?')
# And a later message does not rename it out from under the learner.
with self.reply_with("Another answer."):
_, second = self.ask('And the workup?', conversation_id)
self.assertEqual(second.json()['title'], 'What causes febrile seizures?')
def test_both_turns_are_stored_so_a_thread_can_be_reopened(self):
with self.reply_with("Because of fever [[article:7]]."):
conversation_id, _ = self.ask('why do febrile seizures happen?')
messages = self.client.get(f'/ai/conversations/{conversation_id}').json()['messages']
self.assertEqual([m['role'] for m in messages], ['user', 'assistant'])
# The citations are stored as they were filtered, not recomputed later.
self.assertEqual(messages[1]['citations'][0]['id'], 7)
self.assertEqual(messages[0]['citations'], [])
def test_a_thread_belongs_to_one_learner(self):
with self.reply_with("An answer."):
conversation_id, _ = self.ask('febrile seizure')
self.bank.user = self.bank.peer
# Not 403: whether somebody else's thread exists is not this user's business.
self.assertEqual(self.client.get(f'/ai/conversations/{conversation_id}').status_code, 404)
self.assertEqual(self.client.delete(f'/ai/conversations/{conversation_id}').status_code, 404)
self.assertEqual(self.client.post(
f'/ai/conversations/{conversation_id}/messages', json={'message': 'hello'}).status_code, 404)
self.assertEqual(self.client.get('/ai/conversations').json(), [])
def test_a_model_failure_is_reported_not_stored(self):
conversation_id = self.client.post('/ai/conversations').json()['id']
with patch("litellm.acompletion", new=AsyncMock(side_effect=RuntimeError("down"))):
response = self.client.post(f'/ai/conversations/{conversation_id}/messages',
json={'message': 'febrile seizure'})
self.assertEqual(response.status_code, 502)
# A half-written exchange is worse than none: the question is not kept.
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
def test_deleting_a_thread_takes_its_messages(self):
with self.reply_with("An answer."):
conversation_id, _ = self.ask('febrile seizure')
self.assertEqual(self.client.delete(f'/ai/conversations/{conversation_id}').status_code, 204)
self.assertEqual(self.db.query(Conversation).count(), 0)
self.assertEqual(self.db.query(ConversationMessage).count(), 0)
class RetrievalTests(_AiModeBase):
def test_retrieval_offers_only_what_this_learner_may_see(self):
self.db.add(Article(id=8, slug='draft-febrile', title='Febrile draft',
summary='Unpublished febrile notes', sections=[],
status='draft', user_id=3))
self.db.commit()
self.bank.user = self.bank.owner
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile')
self.assertNotIn(8, [s['id'] for s in found if s['kind'] == 'article'])
# Question 3 is another user's private question.
self.assertNotIn(3, [s['id'] for s in found if s['kind'] == 'question'])
self.bank.user = self.bank.mod
found = ai_mode_service.retrieve(self.db, self.bank.mod, 'febrile')
self.assertIn(8, [s['id'] for s in found if s['kind'] == 'article'])
def test_a_question_source_carries_the_stem_and_not_the_answer(self):
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile seizure')
questions = [s for s in found if s['kind'] == 'question']
self.assertTrue(questions)
for source in questions:
self.assertNotIn('Full explanation', source['text'])
self.assertNotIn('yes', source['text'].split())
def test_a_curated_link_between_two_hits_lifts_both(self):
self.db.add(QuestionArticleLink(question_id=1, article_id=7, section_id=None))
self.db.commit()
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile seizure')
curated = [s for s in found if s.get('curated')]
# An educator tied these two together; both surfacing for one query is
# evidence rather than coincidence.
self.assertIn(('article', 7), [(s['kind'], s['id']) for s in curated])
self.assertIn(('question', 1), [(s['kind'], s['id']) for s in curated])
def test_the_prompt_carries_the_shortlist_and_the_rules(self):
found = ai_mode_service.retrieve(self.db, self.bank.owner, 'febrile')
prompt = ai_mode_service.build_prompt(found)
self.assertIn('[[article:7]]', prompt)
self.assertIn('never cite a marker that is not listed here', prompt)
self.assertIn('Never reveal the answer to a practice question', prompt)

View file

@ -9,9 +9,13 @@ Updated 2026-09-10.
## Design agreed, not built ## Design agreed, not built
- [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation + - [x] **AI Mode (RAG chat)** — done 2026-09-10. `conversations` and
message tables, the retrieval step, the ID-citation contract, and the `conversation_messages` (migration `a5b6c7d8e9f0`), retrieval across
chat UI with a thread rail. sections, articles, questions and cards with the curated-link boost, the
ID-citation contract enforced server-side, and `/ai` with a thread rail.
Answers cite by number and every source opens; a section citation
deep-links into its section. Question sources carry the stem only, so a
chat cannot hand away the practice it is meant to prepare for.
- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus - [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus
at once and `/search` groups the answer by kind, with section hits listed at once and `/search` groups the answer by kind, with section hits listed
under the article they belong to and linked to that section. A header box under the article they belong to and linked to that section. A header box

View file

@ -32,6 +32,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage')) const SearchPage = lazy(() => import('./pages/SearchPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage')) const MediaPage = lazy(() => import('./pages/MediaPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage')) const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage')) const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
@ -104,6 +105,7 @@ function AppRoutes() {
<Route path="/questions/manage" element={<QuestionManagerPage />} /> <Route path="/questions/manage" element={<QuestionManagerPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} /> <Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
<Route path="/ai" element={<AiModePage />} />
<Route path="/media" element={<MediaPage />} /> <Route path="/media" element={<MediaPage />} />
<Route path="/study-plans" element={<StudyPlansPage />} /> <Route path="/study-plans" element={<StudyPlansPage />} />
<Route path="/study-plans/:id" element={<StudyPlanPage />} /> <Route path="/study-plans/:id" element={<StudyPlanPage />} />

View file

@ -106,6 +106,7 @@ export default function Navbar({ onSignIn, onRegister }) {
const navLinks = user ? [ const navLinks = user ? [
{ to: '/home', label: 'Home' }, { to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/ai', label: 'AI Mode' },
{ to: '/quizzes', label: 'Quizzes' }, { to: '/quizzes', label: 'Quizzes' },
{ to: '/analysis', label: 'Analysis' }, { to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' }, { to: '/question-bank', label: 'Question Bank' },

View file

@ -0,0 +1,90 @@
/* AI Mode: a rail of threads beside the conversation. */
.ai-page { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; }
.ai-rail {
position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
}
.ai-new { width: 100%; margin-bottom: 10px; }
.ai-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.ai-rail li { display: flex; align-items: center; gap: 4px; }
.ai-thread {
flex: 1; min-width: 0; min-height: 38px; padding: 8px 10px;
background: none; border: 0; border-radius: 7px; cursor: pointer;
font: inherit; font-size: 0.84rem; color: var(--text); text-align: left;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ai-thread:hover { background: var(--bg); }
.ai-thread.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
.ai-thread-delete {
flex-shrink: 0; background: none; border: 0; cursor: pointer; padding: 6px;
color: var(--text-subtle); font-size: 0.8rem; opacity: 0;
}
.ai-rail li:hover .ai-thread-delete, .ai-thread-delete:focus-visible { opacity: 1; }
.ai-rail-empty { font-size: 0.83rem; color: var(--text-muted); margin: 8px 2px; }
.ai-rail-toggle { display: none; margin-bottom: 10px; }
.ai-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.ai-intro { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 28px; }
.ai-intro h1 { margin: 0 0 8px; font-size: 1.3rem; }
.ai-intro p { margin: 0 0 10px; color: var(--text-muted); font-size: 0.92rem; line-height: 1.65; max-width: 60ch; }
.ai-intro-note { font-size: 0.86rem !important; color: var(--text-subtle) !important; }
.ai-thread-view { display: flex; flex-direction: column; gap: 12px; }
.ai-msg { max-width: 100%; }
.ai-msg.is-user {
align-self: flex-end; max-width: 78%;
background: var(--option-sel-bg); border-radius: 12px 12px 2px 12px; padding: 10px 14px;
}
.ai-msg.is-user p { margin: 0; font-size: 0.92rem; line-height: 1.55; overflow-wrap: anywhere; }
.ai-msg.is-assistant {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px 12px 12px 2px; padding: 14px 16px;
}
.ai-answer { font-size: 0.93rem; line-height: 1.68; }
.ai-answer > :first-child { margin-top: 0; }
.ai-answer > :last-child { margin-bottom: 0; }
.ai-thinking { margin: 0; color: var(--text-muted); font-size: 0.88rem; }
/* Sources are the point, not a footnote: a claim you can open is different from
one you have to believe. */
.ai-sources {
list-style: none; margin: 14px 0 0; padding: 12px 0 0;
border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 6px;
}
.ai-sources li { display: flex; align-items: baseline; gap: 8px; font-size: 0.84rem; }
.ai-source-num {
flex-shrink: 0; min-width: 20px; height: 20px; border-radius: 10px;
background: var(--option-sel-bg); color: var(--primary);
font-size: 0.7rem; font-weight: 700; display: inline-flex;
align-items: center; justify-content: center;
}
.ai-sources a { color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.ai-sources a:hover { color: var(--primary); text-decoration: underline; }
.ai-curated {
font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
padding: 1px 7px; border-radius: 10px; background: var(--correct-bg); color: var(--correct-fg);
}
.ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; }
.ai-composer { display: flex; gap: 8px; align-items: flex-end; position: sticky; bottom: 0; padding-bottom: 12px; background: var(--bg); }
.ai-composer textarea {
flex: 1; min-width: 0; resize: vertical; padding: 11px 14px;
border: 1px solid var(--border); border-radius: 10px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.92rem;
}
.ai-composer textarea:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); }
.ai-composer .btn { min-height: 44px; }
@media (max-width: 820px) {
.ai-page { grid-template-columns: 1fr; }
.ai-rail { position: static; display: none; max-height: none; }
.ai-rail.is-open { display: block; }
.ai-rail-toggle { display: inline-block; }
.ai-msg.is-user { max-width: 88%; }
}

View file

@ -0,0 +1,219 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import api from '../api/client'
import './AiModePage.css'
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
return fallback
}
const CITATION = /[ \t]*\[\[(article|section|question|card):([A-Za-z0-9#_-]+)\]\]/g
/** Where a citation points. Sections deep-link into the article they belong to. */
export const citationHref = (citation) => {
if (citation.kind === 'section') return `/articles/${citation.id}?section=${citation.section_id}`
if (citation.kind === 'article') return `/articles/${citation.id}`
if (citation.kind === 'question') return `/questions/${citation.id}`
return '/flashcards'
}
/**
* Turn the markers left in the prose into numbered links.
*
* The server has already deleted anything the model invented, so every marker
* that reaches here has a source behind it. Numbering rather than inlining the
* title keeps a sentence readable when it rests on three sources.
*/
function Answer({ content, citations }) {
const index = new Map(citations.map((c, i) => [c.marker, i + 1]))
// The match swallows the space before the marker, so the number replaces it
// rather than following it and leaving a double gap.
const numbered = content.replace(CITATION, (_match, kind, ref) => {
const number = index.get(`[[${kind}:${ref}]]`)
return number ? ` [${number}]` : ''
})
return (
<div className="ai-answer">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{numbered}</ReactMarkdown>
{citations.length > 0 && (
<ol className="ai-sources">
{citations.map((citation, i) => (
<li key={citation.marker}>
<span className="ai-source-num">{i + 1}</span>
<Link to={citationHref(citation)}>{citation.title}</Link>
{/* An educator tied this source to another one in the answer;
that is a stronger claim than retrieval ranking it highly. */}
{citation.curated && <span className="ai-curated" title="Linked by an educator">linked</span>}
</li>
))}
</ol>
)}
</div>
)
}
/**
* A chat that can only answer from this learner's own library.
*
* The rail of threads is the same shape as every other chat because that is
* what people already know; what is different is underneath. Retrieval decides
* what the model may cite, the server deletes anything else, and the answer
* carries its sources so a claim can be checked rather than believed.
*/
export default function AiModePage() {
const [threads, setThreads] = useState([])
const [activeId, setActiveId] = useState(null)
const [messages, setMessages] = useState([])
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [railOpen, setRailOpen] = useState(false)
const endRef = useRef(null)
const loadThreads = useCallback(() => api.get('/ai/conversations')
.then(res => { setThreads(res.data || []); return res.data || [] })
.catch(() => []), [])
useEffect(() => {
loadThreads().then(rows => {
if (rows.length) setActiveId(rows[0].id)
setLoading(false)
})
}, [loadThreads])
useEffect(() => {
if (activeId == null) { setMessages([]); return }
api.get(`/ai/conversations/${activeId}`)
.then(res => setMessages(res.data.messages || []))
.catch(() => setError('Could not open that conversation'))
}, [activeId])
useEffect(() => { endRef.current?.scrollIntoView?.({ behavior: 'smooth' }) }, [messages, sending])
const startThread = async () => {
setError('')
try {
const res = await api.post('/ai/conversations')
setThreads(prev => [{ id: res.data.id, title: res.data.title, message_count: 0 }, ...prev])
setActiveId(res.data.id)
setMessages([])
setRailOpen(false)
} catch (err) { setError(apiError(err, 'Could not start a conversation')) }
}
const removeThread = async (id) => {
setError('')
try {
await api.delete(`/ai/conversations/${id}`)
const rest = threads.filter(t => t.id !== id)
setThreads(rest)
if (activeId === id) setActiveId(rest[0]?.id ?? null)
} catch (err) { setError(apiError(err, 'Could not delete that conversation')) }
}
const send = async (event) => {
event?.preventDefault?.()
const text = draft.trim()
if (!text || sending) return
let threadId = activeId
setError('')
setSending(true)
// The question appears immediately; waiting on a round trip to see your own
// words makes the whole thing feel broken.
setMessages(prev => [...prev, { id: `pending-${Date.now()}`, role: 'user', content: text, citations: [] }])
setDraft('')
try {
if (threadId == null) {
const created = await api.post('/ai/conversations')
threadId = created.data.id
setActiveId(threadId)
setThreads(prev => [{ id: threadId, title: 'New chat', message_count: 0 }, ...prev])
}
const res = await api.post(`/ai/conversations/${threadId}/messages`, { message: text })
setMessages(prev => [...prev, res.data.message])
setThreads(prev => prev.map(t => t.id === threadId ? { ...t, title: res.data.title } : t))
} catch (err) {
setError(apiError(err, 'AI Mode is unavailable right now'))
setMessages(prev => prev.filter(m => !String(m.id).startsWith('pending-')))
setDraft(text) // Handing the question back rather than losing it.
} finally { setSending(false) }
}
return (
<div className="ai-page">
<button className="ai-rail-toggle" aria-expanded={railOpen}
onClick={() => setRailOpen(v => !v)}>
{railOpen ? '✕ Close chats' : '☰ Chats'}
</button>
<aside className={`ai-rail${railOpen ? ' is-open' : ''}`}>
<button className="btn btn-primary btn-sm ai-new" onClick={startThread}>New chat</button>
<ul>
{threads.map(thread => (
<li key={thread.id}>
<button className={`ai-thread${thread.id === activeId ? ' is-active' : ''}`}
onClick={() => { setActiveId(thread.id); setRailOpen(false) }}>
{thread.title}
</button>
<button className="ai-thread-delete" aria-label={`Delete ${thread.title}`}
onClick={() => removeThread(thread.id)}></button>
</li>
))}
</ul>
{!loading && threads.length === 0 && <p className="ai-rail-empty">No chats yet.</p>}
</aside>
<main className="ai-main">
{messages.length === 0 && !sending ? (
<div className="ai-intro">
<h1>AI Mode</h1>
<p>
Ask about anything in your library. Answers come from your own reading,
questions and cards and every claim carries the source it came from,
so you can check it rather than take its word.
</p>
<p className="ai-intro-note">
If your library does not cover something, it says so instead of
filling the gap with something you cannot verify.
</p>
</div>
) : (
<div className="ai-thread-view">
{messages.map(message => (
<div key={message.id} className={`ai-msg is-${message.role}`}>
{message.role === 'user'
? <p>{message.content}</p>
: <Answer content={message.content} citations={message.citations || []} />}
</div>
))}
{sending && (
<div className="ai-msg is-assistant">
<p className="ai-thinking" role="status">Searching your library</p>
</div>
)}
<div ref={endRef} />
</div>
)}
{error && <p className="ai-error" role="alert">{error}</p>}
<form className="ai-composer" onSubmit={send}>
<textarea value={draft} rows={2} aria-label="Ask AI Mode"
placeholder="Ask about anything in your library…"
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) send(e) }} />
<button className="btn btn-primary" type="submit" disabled={sending || !draft.trim()}>
{sending ? 'Asking…' : 'Ask'}
</button>
</form>
</main>
</div>
)
}

View file

@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import AiModePage, { citationHref } from './AiModePage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
const threads = [{ id: 1, title: 'Febrile seizures', message_count: 2, updated_at: null }]
const answer = {
id: 22, role: 'assistant',
content: 'Fever first [[article:7]]. Then a workup [[section:7#abc]].',
citations: [
{ marker: '[[article:7]]', kind: 'article', id: 7, section_id: null, title: 'Febrile seizures', curated: true },
{ marker: '[[section:7#abc]]', kind: 'section', id: 7, section_id: 'abc', title: 'Febrile seizures Workup', curated: false },
],
}
const mockApi = (rows = threads, messages = []) => api.get.mockImplementation(url => {
if (url === '/ai/conversations') return Promise.resolve({ data: rows })
if (url.startsWith('/ai/conversations/')) return Promise.resolve({ data: { id: 1, title: 'Febrile seizures', messages } })
return Promise.resolve({ data: [] })
})
const mount = () => render(<MemoryRouter><AiModePage /></MemoryRouter>)
describe('AI Mode', () => {
beforeEach(() => { vi.clearAllMocks(); mockApi() })
it('turns markers into numbered links you can open', async () => {
mockApi(threads, [answer])
mount()
const message = (await screen.findByText(/Fever first/)).closest('.ai-msg')
// The marker itself never reaches the reader.
expect(message.textContent).not.toContain('[[article:7]]')
expect(message.textContent).toContain('Fever first [1]')
const sources = within(message).getAllByRole('link')
expect(sources[0]).toHaveAttribute('href', '/articles/7')
// A section citation deep-links into the section it came from.
expect(sources[1]).toHaveAttribute('href', '/articles/7?section=abc')
})
it('says which sources an educator linked, since that is a stronger claim', async () => {
mockApi(threads, [answer])
mount()
await screen.findByText(/Fever first/)
const sources = document.querySelector('.ai-sources')
const curated = within(sources).getByText('Febrile seizures').closest('li')
expect(within(curated).getByText('linked')).toBeInTheDocument()
})
it('shows the question straight away rather than after a round trip', async () => {
mount()
await screen.findByRole('button', { name: 'Febrile seizures' })
let resolve
api.post.mockReturnValue(new Promise(r => { resolve = r }))
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'what is the workup?')
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
expect(screen.getByText('what is the workup?')).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Searching your library')
resolve({ data: { message: answer, title: 'Febrile seizures' } })
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
})
it('hands the question back when the answer fails, rather than losing it', async () => {
mount()
await screen.findByRole('button', { name: 'Febrile seizures' })
api.post.mockRejectedValue({ response: { data: { detail: "You've reached today's AI Mode limit of 60 messages." } } })
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'one more question')
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
expect(await screen.findByRole('alert')).toHaveTextContent("today's AI Mode limit")
// Typed words are not something to throw away on a failure.
expect(screen.getByLabelText('Ask AI Mode')).toHaveValue('one more question')
expect(screen.queryByText('one more question', { selector: 'p' })).not.toBeInTheDocument()
})
it('starts a thread on the first question when none is open', async () => {
mockApi([])
mount()
await screen.findByRole('heading', { name: 'AI Mode' })
api.post.mockImplementation(url => url === '/ai/conversations'
? Promise.resolve({ data: { id: 5, title: 'New chat' } })
: Promise.resolve({ data: { message: answer, title: 'What is jaundice?' } }))
await userEvent.type(screen.getByLabelText('Ask AI Mode'), 'What is jaundice?')
await userEvent.click(screen.getByRole('button', { name: 'Ask' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/ai/conversations/5/messages',
{ message: 'What is jaundice?' }))
// The thread takes its name from the question, not from "New chat".
expect(await screen.findByRole('button', { name: 'What is jaundice?' })).toBeInTheDocument()
})
it('is honest about what it will not do before you ask', async () => {
mockApi([])
mount()
expect(await screen.findByText(/says so instead of filling the gap/)).toBeInTheDocument()
})
it('deletes a thread and moves off it', async () => {
mount()
await screen.findByRole('button', { name: 'Febrile seizures' })
api.delete.mockResolvedValue({})
await userEvent.click(screen.getByRole('button', { name: 'Delete Febrile seizures' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/ai/conversations/1'))
expect(screen.getByText('No chats yet.')).toBeInTheDocument()
})
it('sends a card citation to the deck list, which is where a card lives', () => {
expect(citationHref({ kind: 'card', id: 3 })).toBe('/flashcards')
expect(citationHref({ kind: 'question', id: 9 })).toBe('/questions/9')
})
})