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) => ( +
  1. + {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} +
  2. + ))} +
+ )} +
+ ) +} + +/** + * 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 ( +
+ + + + +
+ {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}

} + +
+