Retrieval generalised beyond questions
`_text_for_question`, `embed_question` and `hybrid_question_ids` all hardcoded
the questions table, so there was nothing to call for an article or a card. That
layer is now corpus-agnostic:
- `Embeddable` mixin gives articles and flashcards the same embedding,
embedding_model and embedded_at columns questions have, plus a weighted
full-text vector (migration u3a4b5c6d7e8).
- `embed_record(row, kind)` is one code path for all three — they share an
embedding space, so they must share the model and provenance rules too.
- `hybrid_ids(db, query, kind)` ranks any corpus; `hybrid_question_ids` stays as
a thin alias for existing callers.
- Article and flashcard search moved off `ILIKE '%term%'`, which could not find
a jaundice article from "yellow newborn".
- The retry task and full regeneration now sweep every corpus, and the health
report breaks down current/stale/missing per kind.
- Articles embed on create and on edit, with failures left to the retry task.
Quoted phrases replace the keyword-only mode
`websearch_to_tsquery` already gives "absence seizure" exact-phrase semantics,
and the semantic ranker sits out a quoted query. That covers the one case a
keyword-only toggle was for — exact lookup — per query rather than as a sticky
setting whose every position returns a subset of the default.
Full-page question editor (/questions/new, /questions/:id)
Editing happened in a cramped modal. There is now a page with room for the stem,
per-option explanations, a searchable category picker with primary plus extras,
difficulty, and images. It shows the question's id with a copy button, and
Duplicate creates a variant without retyping the stem. `GET /questions/detail/{id}`
backs it, pathed under /detail/ so it cannot shadow the static routes.
Question bank filter bar restyled — the toggle and count read as one control
instead of two grey pills crowding the result count.
Tests: 101 backend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
49 lines
2 KiB
Python
49 lines
2 KiB
Python
"""Give articles and flashcards the same retrieval columns as questions.
|
|
|
|
Both were searched with `ILIKE '%term%'`, so "yellow newborn" could not find a
|
|
jaundice article. They now carry a full-text vector and an embedding, with the
|
|
same provenance columns that make a model change visible.
|
|
|
|
Revision ID: u3a4b5c6d7e8
|
|
Revises: t2f3a4b5c697
|
|
"""
|
|
from alembic import op
|
|
|
|
revision = "u3a4b5c6d7e8"
|
|
down_revision = "t2f3a4b5c697"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
TARGETS = {
|
|
# table: expression whose text is indexed, weighted title/name first
|
|
"articles": """
|
|
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
|
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
|
|
setweight(to_tsvector('english', coalesce(content, '')), 'C')
|
|
""",
|
|
"flashcards": """
|
|
setweight(to_tsvector('english', coalesce(front, '')), 'A') ||
|
|
setweight(to_tsvector('english', coalesce(back, '')), 'B')
|
|
""",
|
|
}
|
|
|
|
|
|
def upgrade():
|
|
for table, expression in TARGETS.items():
|
|
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding vector(1024)")
|
|
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(120)")
|
|
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS embedded_at TIMESTAMP")
|
|
op.execute(f"""
|
|
ALTER TABLE {table} ADD COLUMN IF NOT EXISTS search_vector tsvector
|
|
GENERATED ALWAYS AS ({expression}) STORED
|
|
""")
|
|
op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_search_vector ON {table} USING GIN (search_vector)")
|
|
op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_embedding_model ON {table}(embedding_model)")
|
|
|
|
|
|
def downgrade():
|
|
for table in TARGETS:
|
|
op.execute(f"DROP INDEX IF EXISTS ix_{table}_embedding_model")
|
|
op.execute(f"DROP INDEX IF EXISTS ix_{table}_search_vector")
|
|
for column in ("search_vector", "embedded_at", "embedding_model", "embedding"):
|
|
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS {column}")
|