**Cross-references can name a section.** `[[264#workup|the workup]]` opens the reader at that heading, which is what a sentence about one part of a long article actually means. Whole-article `[[264|label]]` is unchanged, and a section renamed since is not a broken link — it lands at the top of the right article, which is a mild disappointment rather than a dead end. **A picker that writes the marker for you.** 🔗 Link an article, in the editor: type a few words, click the article — or one of its sections — and the marker is on the clipboard with the right title as its label. Getting an id used to mean opening the library in another tab, finding the article and reading the number out of the address bar, which is four steps and a chance to mistype, every time. Its own small endpoint, because the listing deliberately does not carry sections and this needs nothing else. **Three things about cards that were built but never drawn:** - A card can carry an image. The column is there, the API returns it, the editor accepts one — and no view in the app rendered it, so every picture anybody attached to a card was stored and never seen. Both card views show it now, small until clicked like every other figure. - The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The study view has rendered them as links for a while; now both do. - There was no way to make a deck by hand. Every deck came out of a model — generated from a document section or an article — so an educator who wanted to write six cards had nowhere to put them, and the add-a-card route could only add to a deck that did not exist yet. `+ New deck` on the cards page. **Generate cards ran in silence.** It starts a real job, and the only place its progress was drawn was inside the refine panel — which lives in the editor and is shut. Pressing it on the reading page did nothing visible for ninety seconds. It now says what it is doing where it was pressed. **Overlays were invisible to learners.** A stored width is a fraction of the image, and the stroke is drawn with `non-scaling-stroke`, which makes `stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a pixel. The editor has always multiplied by its rendered width; the viewer now does the same sum. Every region an educator has ever marked was invisible to everyone who was not editing it. Also: the figure viewer no longer scrolls, at any width, and the page behind it is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a figure opened half-way down an article drifted while it was read). Options are full width on a phone. The question toolbar's seven glyphs are four, with the rest folded into the ⋯ that was already there, spelled out in words. The jobs popover closes on a click anywhere outside it. And the editor has a way back to Editorial — "back to the article", from an article you opened to edit, is a loop. The contract snapshot caught both new routes on the way through, which is what it is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Article editing rules: variants, revisions, slugs and cross-reference markers.
|
|
|
|
Three things live here because they are decisions rather than plumbing, and each
|
|
one has a failure mode worth naming.
|
|
|
|
**Variants.** One article is read three ways — short, long, and the clinical view
|
|
that carries management and doses. They are one article rather
|
|
than three because they describe one condition: split into three rows they drift
|
|
apart, and a question linked to "Bronchiolitis" would have to pick which of the
|
|
three it meant. Each section carries the variant it belongs to.
|
|
|
|
**Slugs.** A cross-reference written today has to survive a rename tomorrow, so
|
|
every slug an article has ever had is kept and old ones redirect. Without this a
|
|
rename silently breaks every article pointing at the old name, and the person who
|
|
renamed it never finds out.
|
|
|
|
**Markers.** `[[7|Febrile seizures]]` resolves by id and displays the text. The
|
|
id is what makes it rename-proof; the text is what makes the prose readable while
|
|
you are writing it.
|
|
"""
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.article import (
|
|
Article, ArticleRevision, ArticleSectionIndex, ArticleSlug,
|
|
)
|
|
from app.services import embedding_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Short first: it is the fastest way to tell whether this is the article you
|
|
# wanted. Long is one click away and is what an article written before variants
|
|
# existed becomes, since that is what it was.
|
|
VARIANTS = ("short", "long", "clinical")
|
|
DEFAULT_VARIANT = "long"
|
|
|
|
# [[7|Febrile seizures]] — id first, because the id is the part that must not
|
|
# change. [[7#workup|the workup]] lands on one section of it, so a sentence can
|
|
# point at the paragraph it is actually about rather than at the top of a long
|
|
# article. [[febrile-seizures]] is the older slug form and still resolves.
|
|
MARKER_RE = re.compile(
|
|
r"\[\[(?:(\d+)(?:#([A-Za-z0-9_-]+))?\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
|
|
|
|
STATUSES = ("draft", "in_review", "published")
|
|
|
|
|
|
def normalise_sections(sections: list[dict]) -> list[dict]:
|
|
"""Stamp every section with a variant, defaulting to the full article.
|
|
|
|
Articles written before variants existed have no such field; treating them
|
|
as the long version is what keeps them rendering unchanged.
|
|
"""
|
|
out = []
|
|
for section in sections:
|
|
row = dict(section)
|
|
if row.get("variant") not in VARIANTS:
|
|
row["variant"] = DEFAULT_VARIANT
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def sections_for(article: Article, variant: str) -> list[dict]:
|
|
"""The sections of one view, in order."""
|
|
variant = variant if variant in VARIANTS else DEFAULT_VARIANT
|
|
return [s for s in normalise_sections(article.sections or [])
|
|
if s.get("variant") == variant]
|
|
|
|
|
|
def section_title(article: Article, section_id: str | None) -> str | None:
|
|
"""The name of the section a link points at, or None for a whole-article link.
|
|
|
|
A link whose section has since been deleted reads as a whole-article link
|
|
rather than as nothing: the article is still the right reading, only the
|
|
part of it is gone.
|
|
"""
|
|
if not section_id:
|
|
return None
|
|
for section in article.sections or []:
|
|
if section.get("id") == section_id:
|
|
return section.get("title") or section.get("slug")
|
|
return None
|
|
|
|
|
|
def available_variants(article: Article) -> list[str]:
|
|
"""Which views this article actually has, so the toggle offers no empty one."""
|
|
present = {s.get("variant") for s in normalise_sections(article.sections or [])}
|
|
return [v for v in VARIANTS if v in present]
|
|
|
|
|
|
def record_slug(db: Session, article: Article) -> None:
|
|
"""Remember the article's current slug, so it keeps resolving after a rename."""
|
|
existing = db.query(ArticleSlug).filter(ArticleSlug.slug == article.slug).first()
|
|
if existing is None:
|
|
db.add(ArticleSlug(slug=article.slug, article_id=article.id))
|
|
elif existing.article_id != article.id:
|
|
# Another article now owns this name. The newest claim wins, because the
|
|
# alternative is a redirect that sends readers to the wrong condition.
|
|
existing.article_id = article.id
|
|
|
|
|
|
def resolve_slug(db: Session, slug: str) -> Article | None:
|
|
"""Find an article by numeric id, by its current slug, or by an old one.
|
|
|
|
Cross-references in prose are written `[[403|urethritis]]` — by id, because
|
|
an id survives a rename and a slug does not. So whatever resolves a
|
|
reference has to accept both: looking only at slugs made every one of those
|
|
links dead, with no preview and a 404 behind it.
|
|
"""
|
|
reference = (slug or "").strip().lower()
|
|
if not reference:
|
|
return None
|
|
def alive(article):
|
|
return article if article is not None and article.deleted_at is None else None
|
|
|
|
if reference.isdigit():
|
|
return alive(db.get(Article, int(reference)))
|
|
article = db.query(Article).filter(Article.slug == reference,
|
|
Article.deleted_at.is_(None)).first()
|
|
if article:
|
|
return article
|
|
historical = db.query(ArticleSlug).filter(ArticleSlug.slug == reference).first()
|
|
return alive(db.get(Article, historical.article_id)) if historical else None
|
|
|
|
|
|
def snapshot(db: Session, article: Article, user_id: int | None, note: str | None = None) -> None:
|
|
"""Keep what the article was, before it becomes something else."""
|
|
db.add(ArticleRevision(
|
|
article_id=article.id, title=article.title, summary=article.summary,
|
|
content=article.content, sections=article.sections or [],
|
|
references_json=article.references_json, status=article.status,
|
|
note=(note or "")[:200] or None, created_by=user_id,
|
|
))
|
|
|
|
|
|
def marker_targets(text: str) -> tuple[set[int], set[str]]:
|
|
"""The article ids and legacy slugs a piece of prose points at.
|
|
|
|
The section part of `[[7#workup|…]]` is not returned: what makes a marker
|
|
broken is the article being gone. A section that has been renamed since
|
|
leaves the reader at the top of the right article, which is a mild
|
|
disappointment rather than a dead link.
|
|
"""
|
|
ids, slugs = set(), set()
|
|
for match in MARKER_RE.finditer(text or ""):
|
|
if match.group(1):
|
|
ids.add(int(match.group(1)))
|
|
elif match.group(4):
|
|
slugs.add(match.group(4))
|
|
return ids, slugs
|
|
|
|
|
|
def broken_markers(db: Session, article: Article) -> list[str]:
|
|
"""Markers in this article that point at nothing.
|
|
|
|
Checked when an article is saved, because that is the last moment the person
|
|
who wrote the link is still looking at it. A dead cross-reference found a
|
|
week later belongs to nobody.
|
|
"""
|
|
body = " ".join(filter(None, [
|
|
article.content or "",
|
|
*[(s.get("content") or "") for s in (article.sections or [])],
|
|
]))
|
|
ids, slugs = marker_targets(body)
|
|
broken = []
|
|
if ids:
|
|
known = {row[0] for row in db.query(Article.id).filter(Article.id.in_(ids)).all()}
|
|
broken.extend(f"[[{missing}|…]]" for missing in sorted(ids - known))
|
|
for slug in sorted(slugs):
|
|
if resolve_slug(db, slug) is None:
|
|
broken.append(f"[[{slug}]]")
|
|
return broken
|
|
|
|
|
|
def upgrade_markers(db: Session, text: str) -> str:
|
|
"""Rewrite legacy slug markers to the id form, keeping the title as the label.
|
|
|
|
Done on save rather than in a migration: an article nobody has touched is not
|
|
broken, and rewriting prose that no one asked to change is how you lose an
|
|
author's trust in the editor.
|
|
"""
|
|
def replace(match: re.Match) -> str:
|
|
if match.group(1):
|
|
return match.group(0)
|
|
slug = match.group(4)
|
|
article = resolve_slug(db, slug)
|
|
return f"[[{article.id}|{article.title}]]" if article else match.group(0)
|
|
|
|
return MARKER_RE.sub(replace, text or "")
|
|
|
|
|
|
def set_status(db: Session, article: Article, status: str, user_id: int | None) -> None:
|
|
"""Move an article through draft → in_review → published."""
|
|
if status not in STATUSES:
|
|
raise ValueError(f"Unknown status: {status}")
|
|
now = datetime.utcnow()
|
|
if status == "in_review" and article.status != "in_review":
|
|
article.submitted_at = now
|
|
if status == "published" and article.status != "published":
|
|
article.reviewed_at = now
|
|
article.reviewed_by = user_id
|
|
article.status = status
|
|
|
|
|
|
def readable_articles(db, user):
|
|
"""A query over the articles this person may read.
|
|
|
|
Published, plus their own drafts, plus everything if they moderate. The
|
|
rule was written out inline at every place that needed it, which is how a
|
|
draft ends up reachable from one route and not another.
|
|
"""
|
|
from app.models.article import Article
|
|
|
|
# Never the trash, for anybody — including a moderator. Restoring is done
|
|
# from the trash list, which is one place, rather than from wherever an
|
|
# article happens to still be referenced.
|
|
query = db.query(Article).filter(Article.deleted_at.is_(None))
|
|
if getattr(user, "is_moderator", False):
|
|
return query
|
|
return query.filter((Article.status == "published") | (Article.user_id == user.id))
|
|
|
|
|
|
def rebuild_section_index(db: Session, article: Article) -> int:
|
|
"""Mirror the article's JSON sections into their own searchable rows.
|
|
|
|
Sections live in a JSON column, so they cannot carry a vector or a full-text
|
|
index themselves. Projecting them lets a citation point at the right section
|
|
rather than the whole article, and it is the only place the body is embedded
|
|
in full — the article's own vector has room for excerpts, not for 8k
|
|
characters of prose.
|
|
|
|
Rows are keyed by section id, so editing a section updates it and removing
|
|
one deletes it. Returns how many vectors were generated, which is what a
|
|
bulk caller needs in order to report cost.
|
|
|
|
Lives here rather than beside the route that first needed it because
|
|
anything that writes `Article.sections` has to call it; a copy that skipped
|
|
it left 323 generated articles with no section rows at all.
|
|
"""
|
|
# Only what a reader can reach. A draft is unfinished by definition, and an
|
|
# index that carries it puts half-written prose into search results and
|
|
# into the shortlist the assistant answers from. Its rows go when it is
|
|
# unpublished, and come back when it is published again — this function is
|
|
# called on every save, so the index follows the status rather than
|
|
# needing to be told about it separately.
|
|
# A binned article is not readable whatever its status says, so it comes out
|
|
# of the index on the same rule as an unpublished one.
|
|
if (article.status or "") != "published" or getattr(article, "deleted_at", None) is not None:
|
|
db.query(ArticleSectionIndex).filter(
|
|
ArticleSectionIndex.article_id == article.id).delete(synchronize_session=False)
|
|
return 0
|
|
|
|
pending, keep = [], set()
|
|
for section in article.sections or []:
|
|
if not isinstance(section, dict):
|
|
continue
|
|
section_id = section.get("id")
|
|
if not section_id:
|
|
continue
|
|
keep.add(section_id)
|
|
row = db.query(ArticleSectionIndex).filter_by(
|
|
article_id=article.id, section_id=section_id).first()
|
|
text_changed = True
|
|
if row is None:
|
|
row = ArticleSectionIndex(article_id=article.id, section_id=section_id)
|
|
db.add(row)
|
|
else:
|
|
text_changed = (row.title != section.get("title")) or (row.content != section.get("content"))
|
|
row.title = section.get("title")
|
|
row.content = section.get("content")
|
|
# Only pay for an embedding when the text actually changed.
|
|
if text_changed or row.embedding is None:
|
|
pending.append(row)
|
|
embedded = embedding_service.embed_records(pending, "article_section") if pending else 0
|
|
|
|
stale = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.article_id == article.id)
|
|
if keep:
|
|
stale = stale.filter(~ArticleSectionIndex.section_id.in_(keep))
|
|
stale.delete(synchronize_session=False)
|
|
return embedded
|
|
|
|
|
|
def reindex(db: Session, article: Article) -> None:
|
|
"""Embed the article and reproject its sections. Failures wait for the retry task."""
|
|
try:
|
|
embedding_service.embed_record(article, "article")
|
|
rebuild_section_index(db, article)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
logger.warning("Could not embed article %s; leaving it for the retry task",
|
|
article.id, exc_info=True)
|