diff --git a/backend/scripts/article_pipeline.py b/backend/scripts/article_pipeline.py new file mode 100644 index 0000000..1e89261 --- /dev/null +++ b/backend/scripts/article_pipeline.py @@ -0,0 +1,164 @@ +"""Retrieval and import for articles written by hand rather than by an API model. + +Splits the job in two, because only one half belongs to a machine: + + fetch — bge-m3 embeds the topic, Milvus returns the passages, and the + references are derived from their metadata. No writing model is + involved at any point. + import — takes a finished article as JSON and stores it as a draft. + +Whoever writes the prose in between reads the passages and writes original text +from them. The references are built here from what retrieval actually returned, +so they cannot be invented by whoever is writing, which is the same property the +generated route had. + + docker compose exec backend python -m scripts.article_pipeline fetch "Croup" --out /app/uploads/_work + docker compose exec backend python -m scripts.article_pipeline topics --limit 40 + docker compose exec backend python -m scripts.article_pipeline import /app/uploads/_work/croup.article.json +""" +import argparse +import json +import pathlib +import sys +import uuid + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal +from app.models.article import Article +from app.services import article_service, clinical_library +from app.services.article_writer import ( + MIN_SOURCE_CHARS, PASSAGES, SHELF, slugify, _unique_slugs, +) + +VARIANTS = ("short", "long", "clinical") + + +def cmd_topics(args): + """Conditions that still have no article, biggest first.""" + db = SessionLocal() + try: + 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() + have = {row[0] for row in db.query(Article.slug).all()} + todo = [(cid, name, uses) for cid, name, uses in rows if slugify(name) not in have] + for category_id, name, uses in todo[:args.limit]: + print(f"{category_id}\t{uses}\t{name}") + print(f"\n# {len(todo)} topics without an article", file=sys.stderr) + finally: + db.close() + return 0 + + +def cmd_fetch(args): + """Everything needed to write one article, and nothing that writes it.""" + passages = clinical_library.search(args.topic, limit=PASSAGES, folder_contains=SHELF) + chars = sum(len(p["text"]) for p in passages) + payload = { + "topic": args.topic, + "slug": slugify(args.topic), + "category_id": args.category_id, + "passage_count": len(passages), + "source_chars": chars, + "enough_material": len(passages) >= 3 and chars >= MIN_SOURCE_CHARS, + "references": clinical_library.references_from(passages), + "passages": [{ + "source": f"{p['source'].get('title', 'source')}" + f"{', p. ' + str(p['source']['page']) if p['source'].get('page') else ''}", + "text": p["text"], + } for p in passages], + } + out = pathlib.Path(args.out) + out.mkdir(parents=True, exist_ok=True) + target = out / f"{payload['slug']}.sources.json" + target.write_text(json.dumps(payload, indent=1)) + print(target) + return 0 + + +def _sections(blocks, variant): + out = [] + for block in blocks or []: + 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({"id": uuid.uuid4().hex, "slug": slugify(title)[:60] or f"s{len(out) + 1}", + "title": title[:300], "content": content, + "parent_id": None, "variant": variant}) + return out + + +def cmd_import(args): + """Store a finished article as a draft, refusing anything half-written.""" + data = json.loads(pathlib.Path(args.path).read_text()) + topic = (data.get("topic") or "").strip() + if not topic: + print(" No topic in that file.") + return 1 + + sections = _unique_slugs([s for v in VARIANTS for s in _sections(data.get(v), v)]) + missing = [v for v in VARIANTS if not any(s["variant"] == v for s in sections)] + if missing: + # A view the reader is offered and finds empty is worse than one that was + # never promised, so an incomplete article is refused rather than stored. + print(f" {topic}: missing {', '.join(missing)} — not imported.") + return 1 + + db = SessionLocal() + try: + slug = data.get("slug") or slugify(topic) + if db.query(Article.id).filter(Article.slug == slug).first(): + print(f" {topic}: already exists.") + return 0 + article = Article( + slug=slug, title=topic[:300], + summary=(data.get("summary") or "").strip()[:2000] or None, + sections=sections, category_id=data.get("category_id"), + status="draft", + references_json=data.get("references") or [], + generated_by=data.get("written_by") or "claude", + ) + db.add(article) + db.flush() + article_service.record_slug(db, article) + db.commit() + print(f" {topic}: imported as draft #{article.id} " + f"({len(sections)} sections, {len(article.references_json)} references)") + finally: + db.close() + return 0 + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="cmd", required=True) + + topics = sub.add_parser("topics", help="conditions still without an article") + topics.add_argument("--limit", type=int, default=50) + topics.set_defaults(func=cmd_topics) + + fetch = sub.add_parser("fetch", help="retrieve the source passages for one topic") + fetch.add_argument("topic") + fetch.add_argument("--category-id", type=int, default=None) + fetch.add_argument("--out", default="/app/uploads/_work") + fetch.set_defaults(func=cmd_fetch) + + imp = sub.add_parser("import", help="store a written article as a draft") + imp.add_argument("path") + imp.set_defaults(func=cmd_import) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx index b7db629..7c400c9 100644 --- a/frontend/src/pages/QuestionManagerPage.jsx +++ b/frontend/src/pages/QuestionManagerPage.jsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' -import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors' +import { CreateQuestionModal } from '../components/QuestionEditors' import GrantsPanel from '../components/GrantsPanel' import './QuestionManagerPage.css' @@ -48,7 +48,6 @@ export default function QuestionManagerPage() { const [selected, setSelected] = useState(() => new Set()) const [bulkBusy, setBulkBusy] = useState(false) const [confirmBulkDelete, setConfirmBulkDelete] = useState(false) - const [editQuestion, setEditQuestion] = useState(null) const [creating, setCreating] = useState(false) const [deletingId, setDeletingId] = useState(null) const debounceRef = useRef(null) @@ -125,14 +124,11 @@ export default function QuestionManagerPage() { } const lastPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1) + // Where the editor sends you back to, filters and page intact. + const location = useLocation() return (
- {editQuestion && ( - { setEditQuestion(null); refresh() }} - onClose={() => setEditQuestion(null)} /> - )} {creating && ( { setCreating(false); refresh() }} @@ -228,7 +224,11 @@ export default function QuestionManagerPage() {
- + {/* The whole question on its own page. The modal could show + neither a searchable category tree nor images, versions and + option explanations at once, which is what editing needs. */} + Edit {deletingId === q.id ? ( <> diff --git a/frontend/src/pages/QuestionManagerPage.test.jsx b/frontend/src/pages/QuestionManagerPage.test.jsx index 2313ec7..46b2d21 100644 --- a/frontend/src/pages/QuestionManagerPage.test.jsx +++ b/frontend/src/pages/QuestionManagerPage.test.jsx @@ -94,3 +94,14 @@ it('surfaces a server error from a bulk action', async () => { await userEvent.click(screen.getByRole('button', { name: 'Share' })) expect(await screen.findByRole('alert')).toHaveTextContent('at most 500 questions') }) + +it('edits on a page of its own, carrying the way back', async () => { + mockApi() + render() + await screen.findByText(/3-year-old with fever/) + const edit = screen.getAllByRole('link', { name: 'Edit' })[0] + // A modal could show neither a searchable category tree nor images, versions + // and option explanations at once. + expect(edit).toHaveAttribute('href', '/questions/1') + expect(screen.queryByRole('dialog', { name: 'Edit Question' })).not.toBeInTheDocument() +})