"""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())