A 2,000px radiograph written into an article rendered at whatever width it happened to be — a wall of greyscale in the middle of a sentence, four megabytes to draw it, and no way to look at it properly. Every image in prose is now a 256px thumbnail with the author's label under it, and a click gives it the screen. The viewer puts what is known about the figure beside it: its title, what it shows, and where it came from. `media_assets` gains `source` and `source_url` for that — a citation belongs to the file, because the same figure used in three articles is cited the same way in all three, and a licence that turns out to be wrong is one row to fix rather than three paragraphs to find. Asked for when the figure is opened, not when the page is drawn. And `overlay`: the regions an educator has marked, as vector shapes in normalised coordinates on the unit square, so one drawing is correct in a thumbnail, in the viewer and on a projector. Off until the learner turns it on — marks shown before they have looked answer the question for them. Vectors rather than a second burnt-in picture, for four reasons written down in docs/image-overlays.md. The tool that draws them is next; this is the storage, the contract and the reader's half. On a narrow screen the description stacks above the image rather than beside it, where it can be read before scrolling to the picture. Also here: `classify_question_difficulty`, which labelled all 2,924 questions in batches of twenty-five against a written rubric — 622 easy, 1,634 medium, 668 hard, no failed batches. The column had been NULL on every row since it existed. Migration l2c3d4e5f6a7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
83 lines
4 KiB
Python
83 lines
4 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import (Column, DateTime, ForeignKey, Integer, JSON, String, Text,
|
|
UniqueConstraint)
|
|
|
|
from app.database import Base
|
|
from app.models.embeddable import Embeddable
|
|
|
|
|
|
class MediaAsset(Base, Embeddable):
|
|
"""An image in the bank, searchable by what it shows rather than its filename.
|
|
|
|
The embedding column is filled from the caption today. A vision-capable model
|
|
can fill it from the image itself later; `embedding_model` records which one
|
|
produced each vector, so the two are never mixed silently.
|
|
"""
|
|
|
|
__tablename__ = "media_assets"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
path = Column(String(500), unique=True, nullable=False)
|
|
title = Column(String(300), nullable=True)
|
|
caption = Column(Text, nullable=True)
|
|
alt_text = Column(Text, nullable=True)
|
|
# Where the image came from, and where to check that. Kept on the asset
|
|
# rather than typed into a caption because a citation belongs to the file:
|
|
# the same figure used in three articles is cited the same way in all
|
|
# three, and a licence that turns out to be wrong is one row to fix.
|
|
source = Column(Text, nullable=True)
|
|
source_url = Column(String(600), nullable=True)
|
|
# Regions an educator has marked on the image, as vector shapes in
|
|
# normalised coordinates — see `docs/image-overlays.md`. Vectors rather
|
|
# than a second burnt-in picture: a raster overlay cannot be turned off,
|
|
# cannot be corrected without redrawing it, doubles the storage, and looks
|
|
# wrong at any size but the one it was drawn at.
|
|
#
|
|
# {"shapes": [{"kind": "path", "points": [[0.31, 0.52], …],
|
|
# "color": "#5eead4", "width": 0.006, "label": "lead lines"}]}
|
|
overlay = Column(JSON, nullable=True)
|
|
kind = Column(String(20), default="image")
|
|
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
# Which backend holds the bytes; reads fall back to the volume either way.
|
|
storage = Column(String(10), default="local")
|
|
byte_size = Column(Integer, nullable=True)
|
|
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
|
|
class MediaTagLink(Base):
|
|
__tablename__ = "media_tag_links"
|
|
__table_args__ = (UniqueConstraint("media_id", "tag_id", name="uq_media_tag"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
media_id = Column(Integer, ForeignKey("media_assets.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
# `question_tags` is created by raw DDL rather than an ORM model, so the
|
|
# constraint is declared in the migration; the mapper only stores the id.
|
|
tag_id = Column(Integer, nullable=False, index=True)
|
|
|
|
|
|
class MediaLibrary(Base):
|
|
"""A named group of images, so access can be given to some without all."""
|
|
|
|
__tablename__ = "media_libraries"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
name = Column(String(200), unique=True, nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
|
|
class MediaLibraryGrant(Base):
|
|
"""Lets one person work with one library, mirroring the category grants."""
|
|
|
|
__tablename__ = "media_library_grants"
|
|
__table_args__ = (UniqueConstraint("library_id", "user_id", name="uq_media_library_grant"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
library_id = Column(Integer, ForeignKey("media_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
granted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|