pdf-quiz-generator/backend/app/services/article_writer.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00

223 lines
9.7 KiB
Python

"""Write a topic article from what the clinical library returns.
The division of labour is deliberate and matches AI Mode: **retrieval supplies
the facts and the provenance; the model supplies the prose.** References are
built from the metadata of the passages that were actually retrieved, never from
the model, so a reference cannot be invented — the same property that makes an
AI Mode citation trustworthy.
What is generated is an original piece of writing grounded in the library, not
an extract from it. A textbook's sentences belong to its publisher; its facts do
not, and an article that reproduced the sentences would be redistribution
whatever produced the copy. The prompt says this plainly, and the length limits
make a long verbatim passage impossible to hide.
Three views come out of one call rather than three, because they are three
readings of one topic and generating them separately lets them contradict each
other on the numbers.
"""
import json
import logging
import re
import uuid
from sqlalchemy.orm import Session
from app.models.article import Article
from app.services import article_service, clinical_library
from app.services.ai_service import _call_model, get_model_for_task
logger = logging.getLogger(__name__)
PASSAGES = 14
# Enough retrieved prose to write from. Below this the model is being asked to
# write a medical article out of fragments, and it will either refuse or invent.
MIN_SOURCE_CHARS = 3000
# Generous for a long article, short enough that a stalled call is noticed in
# minutes rather than discovered hours later with nothing written since.
WRITE_TIMEOUT = 150
# Three views of one topic, the long one about 550 words. Four thousand tokens is
# comfortable for that and keeps each request small enough to be affordable.
WRITE_MAX_TOKENS = 4000
# Long enough to be worth reading, short enough that nobody skims past the point.
LONG_WORDS = 550
SHELF = "Pediatrics"
def slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
return slug[:120] or "topic"
def _prompt(topic: str, passages: list[dict]) -> str:
sources = "\n\n".join(
f"[{i + 1}] {p['source'].get('title', 'source')}"
f"{', p. ' + str(p['source']['page']) if p['source'].get('page') else ''}\n{p['text']}"
for i, p in enumerate(passages)
)
return f"""You are writing a study article about **{topic}** for a paediatric exam-revision platform.
Write it from the reference passages below. They are extracts from standard
textbooks and guidelines. Use them for the facts, the numbers and the structure —
then write the article in your own words. Do not reproduce sentences from the
passages: this is an article about the topic, not an extract from a book.
If the passages do not cover something, leave it out rather than filling the gap
from memory. A gap a reader can see is safer than a confident sentence nobody
can check.
Produce three views of the same topic:
1. "long" — the full article, about {LONG_WORDS} words, in 4 to 7 sections.
Typical headings: Definition, Epidemiology, Etiology, Pathophysiology,
Clinical features, Diagnostics, Differential diagnosis, Treatment,
Complications, Prevention. Use only the ones the passages support.
2. "short" — one section titled "In short", 6 to 10 tight bullets a learner
could revise from the night before an exam. Every bullet must carry a fact:
no bullet that only says a topic is important.
3. "clinical" — one or two sections covering management at the bedside:
what to do, in what order, with drug doses and routes where the passages give
them. Include units and per-kilogram dosing exactly as stated. If a dose is
not in the passages, do not state one.
The summary is two sentences, 30 to 60 words, that make claims — never a list of
the topics the article covers. The first says what the condition is, who gets it
and when; the second says what changes management, or how it ends. "Peanut
allergy prevention and management: early introduction guidelines, risk
stratification, and anaphylaxis treatment" is a table of contents with the colons
filed off: every phrase sounds informative and together they assert nothing.
Write the two sentences a reader finishes knowing something.
Spell everything in American English — pediatric, hemorrhage, edema, anemia,
diarrhea, esophageal, epinephrine — throughout the summary and every section.
The rest of the corpus is American and so is the board this prepares people for.
Return ONLY valid JSON, no markdown fence:
{{
"summary": "two sentences making claims, no heading",
"long": [{{"title": "Definition", "content": "markdown"}}],
"short": [{{"title": "In short", "content": "- bullet\\n- bullet"}}],
"clinical": [{{"title": "Management", "content": "markdown"}}]
}}
Markdown may use lists, bold and tables. Do not write citation markers, footnote
numbers or URLs anywhere: the reference list is attached separately.
REFERENCE PASSAGES
{sources}"""
def _sections(blocks: list[dict], variant: str) -> list[dict]:
"""Turn the model's blocks into stable sections of one view."""
out = []
for block in blocks or []:
# The model occasionally returns a bare string where a block was asked
# for. Skipping it costs one section; letting it through ends the run.
if not isinstance(block, dict):
continue
title = str(block.get("title") or "").strip()
content = str(block.get("content") or "").strip()
if not title or not content:
continue
out.append({
# A fresh id per section, because links are made against these and a
# regenerated article must not silently inherit another's links.
"id": uuid.uuid4().hex,
"slug": slugify(title)[:60] or f"section-{len(out) + 1}",
"title": title[:300],
"content": content,
"parent_id": None,
"variant": variant,
})
return out
def _unique_slugs(sections: list[dict]) -> list[dict]:
"""Slugs are unique within an article, and three views repeat titles."""
seen: set[str] = set()
for section in sections:
base = section["slug"]
candidate, n = base, 2
while candidate in seen:
candidate = f"{base}-{n}"
n += 1
section["slug"] = candidate
seen.add(candidate)
return sections
def write_article(db: Session, topic: str, category_id: int | None = None,
user_id: int | None = None) -> dict:
"""Retrieve, write, and store one article as a draft.
Returns a small report rather than the article, because the caller is a
background run over hundreds of topics and wants to know what happened.
"""
passages = clinical_library.search(
f"{topic} in children: definition, causes, clinical features, diagnosis and management",
limit=PASSAGES, folder_contains=SHELF)
source_chars = sum(len(p["text"]) for p in passages)
if len(passages) < 3 or source_chars < MIN_SOURCE_CHARS:
# Too little to ground an article. Writing one anyway would produce
# exactly the confident unverifiable prose this is designed to avoid,
# and a topic named after a shelf rather than a condition lands here.
return {"topic": topic, "status": "skipped",
"reason": f"only {source_chars} chars from {len(passages)} passages",
"passages": len(passages)}
model_id, api_key = get_model_for_task(db, "extraction")
# One topic must not be able to stall a run of five hundred.
raw = _call_model(_prompt(topic, passages), model_id, api_key,
timeout=WRITE_TIMEOUT, max_tokens=WRITE_MAX_TOKENS)
text = raw.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-z]*\n?|```$", "", text).strip()
try:
data = json.loads(text)
except ValueError:
logger.warning("Article writer returned unparseable JSON for %s", topic)
return {"topic": topic, "status": "failed", "reason": "model did not return JSON"}
if not isinstance(data, dict):
return {"topic": topic, "status": "failed", "reason": "model returned the wrong shape"}
sections = _unique_slugs([
*_sections(data.get("long"), "long"),
*_sections(data.get("short"), "short"),
*_sections(data.get("clinical"), "clinical"),
])
if not sections:
return {"topic": topic, "status": "failed", "reason": "no usable sections"}
slug = slugify(topic)
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
return {"topic": topic, "status": "exists", "article_id": existing.id}
article = Article(
slug=slug, title=topic[:300],
summary=(data.get("summary") or "").strip()[:2000] or None,
content=None, sections=sections, category_id=category_id, user_id=None,
# Never published by generation. A person decides that.
status="draft",
# References come from the retrieved metadata, not from the model, so a
# source cannot be invented.
references_json=clinical_library.references_from(passages),
generated_by=f"clinical-library:{model_id}",
)
from datetime import datetime
article.generated_at = datetime.utcnow()
db.add(article)
db.flush()
article_service.record_slug(db, article)
db.commit()
db.refresh(article)
# The section index is what makes the body searchable at all. This writer
# produced 323 articles without it, so the library's whole depth was
# invisible to search while every one of them looked fine on the page.
article_service.reindex(db, article)
return {"topic": topic, "status": "written", "article_id": article.id,
"sections": len(sections), "references": len(article.references_json or []),
"passages": len(passages)}