Standardises cross-references the way we agreed, and puts a CMS around articles so hundreds of generated drafts are reviewable rather than merely present. Links, made rename-proof `[[7|Febrile seizures]]` resolves by id and displays the text — the id is the part that must not change, the text is what keeps prose readable while you write it. `[[old-slug]]` still resolves and is rewritten to the id form on save, not in a migration: an article nobody has touched is not broken, and rewriting prose no one asked to change is how an editor stops trusting the editor. Every slug an article has ever had is kept, so a rename redirects instead of 404ing, and a save reports markers pointing at nothing — at the moment the person who wrote the link is still looking at it. Three views of one topic The full article to study from, the key points to revise from, the clinical view to act from, with doses. They are views of one article rather than three articles, so the numbers cannot drift apart and a question linked to the topic still means one thing. Each section carries its variant; articles written before this are the long view, unchanged. CMS draft → in review → published, with an author able to submit and only a moderator able to publish. Every save snapshots what was there, restorable, and restoring is itself snapshotted or the way back from a mistaken restore is gone. The editorial queue is work rather than inventory: waiting for review, generated and unread, published without sources, published with nothing to practise, barely written. An empty bucket is drawn as good news, not as an alert. Articles from the clinical library The library index is 1.8M chunks of reference texts embedded with bge-m3 — the same model PedsHub already uses, so our query vectors are directly comparable and nothing had to be re-indexed. Retrieval supplies the facts and the provenance; the model supplies the prose. References are built from the metadata of the passages actually retrieved, never from the model, so a reference cannot be invented — the same property that makes an AI Mode citation trustworthy. A topic with fewer than three grounding passages is skipped rather than written from memory. Everything lands as a draft. Two things worth naming. The generated text is original writing grounded in those books, not extracts from them: their facts are usable, their sentences are their publishers'. And there are two Milvus servers on this host — the collection with the data is the one reached as `milvus`, not the similarly named one on the other stack, which I wired up first and which silently refused. Also fixed along the way: `litellm==1.28.13` has been withdrawn from PyPI, so requirements.txt could no longer be resolved from scratch and the image only built because of a cached layer. Later additions go in their own layer until the pins are refreshed. 182 backend, 223 frontend green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
100 lines
4 KiB
Python
100 lines
4 KiB
Python
"""Write a topic article for every condition that has questions.
|
|
|
|
Long-running and resumable: an article that already exists is skipped, so the
|
|
run can be stopped and restarted without duplicating work or spending a model
|
|
call twice on the same topic.
|
|
|
|
Everything it writes is a draft. Generated medical writing that nobody has read
|
|
must not reach a learner, so publishing stays a decision a person makes on the
|
|
editorial queue.
|
|
|
|
docker compose exec backend python -m scripts.generate_articles
|
|
docker compose exec backend python -m scripts.generate_articles --apply
|
|
docker compose exec backend python -m scripts.generate_articles --apply --limit 25
|
|
"""
|
|
import argparse
|
|
import sys
|
|
import time
|
|
|
|
from sqlalchemy import text as sa_text
|
|
|
|
from app.database import SessionLocal
|
|
from app.models.article import Article
|
|
from app.services import clinical_library
|
|
from app.services.article_writer import slugify, write_article
|
|
|
|
# A pause between topics, so a run of hundreds does not monopolise the model
|
|
# proxy that the rest of the platform shares.
|
|
PAUSE_SECONDS = 1.5
|
|
|
|
|
|
def candidates(db, limit: int | None):
|
|
"""Leaf categories that actually hold questions, biggest first.
|
|
|
|
Leaves because a condition is what an article is about; a discipline is a
|
|
shelf. Biggest first so that stopping the run early still leaves the topics
|
|
carrying the most questions covered.
|
|
"""
|
|
rows = db.execute(sa_text("""
|
|
SELECT c.id, c.name, COUNT(q.id) AS uses
|
|
FROM question_categories c
|
|
JOIN questions q ON q.question_category_id = c.id
|
|
WHERE NOT EXISTS (SELECT 1 FROM question_categories k WHERE k.parent_id = c.id)
|
|
GROUP BY c.id, c.name
|
|
ORDER BY uses DESC, c.name
|
|
""")).fetchall()
|
|
return rows[:limit] if limit else rows
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--apply", action="store_true", help="actually write articles")
|
|
parser.add_argument("--limit", type=int, default=None, help="stop after this many topics")
|
|
parser.add_argument("--user-id", type=int, default=None, help="author to record")
|
|
args = parser.parse_args()
|
|
|
|
if args.apply and not clinical_library.available():
|
|
print(" The clinical library index is unreachable; refusing to write "
|
|
"articles with nothing to ground them in.")
|
|
return 1
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
topics = candidates(db, args.limit)
|
|
existing = {row[0] for row in db.query(Article.slug).all()}
|
|
todo = [(cid, name, uses) for cid, name, uses in topics if slugify(name) not in existing]
|
|
|
|
print(f" conditions with questions : {len(topics)}")
|
|
print(f" already written : {len(topics) - len(todo)}")
|
|
print(f" to write : {len(todo)}")
|
|
if not args.apply:
|
|
print("\n First 15:")
|
|
for _cid, name, uses in todo[:15]:
|
|
print(f" {uses:4d} {name}")
|
|
print("\n Re-run with --apply to write them. Everything lands as a draft.")
|
|
return 0
|
|
|
|
written = skipped = failed = 0
|
|
for index, (category_id, name, uses) in enumerate(todo, start=1):
|
|
try:
|
|
result = write_article(db, name, category_id=category_id, user_id=args.user_id)
|
|
except Exception as error: # one bad topic must not end a run of hundreds
|
|
db.rollback()
|
|
result = {"status": "failed", "reason": str(error)[:120]}
|
|
status = result.get("status")
|
|
written += status == "written"
|
|
skipped += status in ("skipped", "exists")
|
|
failed += status == "failed"
|
|
note = result.get("reason", "")
|
|
print(f" [{index}/{len(todo)}] {name[:52]:<52} {status:<8} {note[:40]}", flush=True)
|
|
time.sleep(PAUSE_SECONDS)
|
|
|
|
print(f"\n written : {written}\n skipped : {skipped}\n failed : {failed}")
|
|
print(" All drafts. Review them in the editorial queue before publishing.")
|
|
finally:
|
|
db.close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|