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

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

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

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

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

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

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

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

156 lines
6.5 KiB
Python

"""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)}