diff --git a/backend/alembic/versions/a5b6c7d8e9f0_conversations.py b/backend/alembic/versions/a5b6c7d8e9f0_conversations.py
new file mode 100644
index 0000000..b1f1aa2
--- /dev/null
+++ b/backend/alembic/versions/a5b6c7d8e9f0_conversations.py
@@ -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")
diff --git a/backend/app/main.py b/backend/app/main.py
index 0374d93..6b4c317 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL)
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 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.scheduler import start_scheduler, stop_scheduler
@@ -168,7 +168,7 @@ def setup_pgvector():
# 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 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.
# 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(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
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")
diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py
new file mode 100644
index 0000000..048402f
--- /dev/null
+++ b/backend/app/models/conversation.py
@@ -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")
diff --git a/backend/app/routers/ai_mode.py b/backend/app/routers/ai_mode.py
new file mode 100644
index 0000000..dd9efa1
--- /dev/null
+++ b/backend/app/routers/ai_mode.py
@@ -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)}
diff --git a/backend/app/services/ai_mode_service.py b/backend/app/services/ai_mode_service.py
new file mode 100644
index 0000000..4bc87e4
--- /dev/null
+++ b/backend/app/services/ai_mode_service.py
@@ -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
diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py
new file mode 100644
index 0000000..9a30edd
--- /dev/null
+++ b/backend/tests/test_ai_mode.py
@@ -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)
diff --git a/docs/TODO.md b/docs/TODO.md
index 889d17d..6c071f0 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -9,9 +9,13 @@ Updated 2026-09-10.
## Design agreed, not built
-- [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation +
- message tables, the retrieval step, the ID-citation contract, and the
- chat UI with a thread rail.
+- [x] **AI Mode (RAG chat)** — done 2026-09-10. `conversations` and
+ `conversation_messages` (migration `a5b6c7d8e9f0`), retrieval across
+ 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
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
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e3a59b1..803b56b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -32,6 +32,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
+const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
@@ -104,6 +105,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 1db178c..9df3b43 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -106,6 +106,7 @@ export default function Navbar({ onSignIn, onRegister }) {
const navLinks = user ? [
{ to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' },
+ { to: '/ai', label: 'AI Mode' },
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css
new file mode 100644
index 0000000..d28e37c
--- /dev/null
+++ b/frontend/src/pages/AiModePage.css
@@ -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%; }
+}
diff --git a/frontend/src/pages/AiModePage.jsx b/frontend/src/pages/AiModePage.jsx
new file mode 100644
index 0000000..96df33a
--- /dev/null
+++ b/frontend/src/pages/AiModePage.jsx
@@ -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 (
+
+
{numbered}
+ {citations.length > 0 && (
+
+ {citations.map((citation, i) => (
+
+ {i + 1}
+ {citation.title}
+ {/* An educator tied this source to another one in the answer;
+ that is a stronger claim than retrieval ranking it highly. */}
+ {citation.curated && linked }
+
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * 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 (
+
+
setRailOpen(v => !v)}>
+ {railOpen ? '✕ Close chats' : '☰ Chats'}
+
+
+
+ New chat
+
+ {threads.map(thread => (
+
+ { setActiveId(thread.id); setRailOpen(false) }}>
+ {thread.title}
+
+ removeThread(thread.id)}>✕
+
+ ))}
+
+ {!loading && threads.length === 0 && No chats yet.
}
+
+
+
+ {messages.length === 0 && !sending ? (
+
+
AI Mode
+
+ 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.
+
+
+ If your library does not cover something, it says so instead of
+ filling the gap with something you cannot verify.
+
+
+ ) : (
+
+ {messages.map(message => (
+
+ {message.role === 'user'
+ ?
{message.content}
+ :
}
+
+ ))}
+ {sending && (
+
+
Searching your library…
+
+ )}
+
+
+ )}
+
+ {error && {error}
}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/AiModePage.test.jsx b/frontend/src/pages/AiModePage.test.jsx
new file mode 100644
index 0000000..e0d631f
--- /dev/null
+++ b/frontend/src/pages/AiModePage.test.jsx
@@ -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( )
+
+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')
+ })
+})