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
41 lines
1.5 KiB
Python
41 lines
1.5 KiB
Python
"""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")
|