feat: question manager edits on a page, and articles written without a writing API

The manager still opened a modal
The bank was moved to the full editor a while back and this page was missed, so
editing from the manager still meant a dialog whose category control was a flat
select of seven hundred breadcrumb strings — no search, no way to pick a branch
and then narrow within it, and too small to follow. The full page already has
the searchable drill-down with sub-selection, images, versions and option
explanations. Edit now goes there and carries the way back, filters and page
intact. The modal stays where a quick correction belongs.

Articles, written rather than generated
Per the user's instruction: no OpenAI, no OpenRouter for writing — bge-m3 for
the search and nothing else. `scripts/article_pipeline.py` splits the job so
only the machine half is machine work:

    topics  — conditions that still have no article, biggest first
    fetch   — embed the topic, search the library, write the passages and the
              references derived from their metadata to a file
    import  — take a finished article and store it as a draft

No model API is called at any point in that pipeline. Whoever writes the prose
reads the passages and writes original text from them; the references still come
from what retrieval actually returned, so they cannot be invented by the writer
either — the same property the generated route had, kept.

The importer refuses an article missing any of short, long or clinical. A view a
reader is offered and finds empty is worse than one that was never promised.

244 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 02:02:57 +02:00
parent a1459b2965
commit 07eae7937b
3 changed files with 184 additions and 9 deletions

View file

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

View file

@ -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 (
<div className="qm-page">
{editQuestion && (
<QuestionEditModal question={editQuestion} categories={categories}
onSaved={() => { setEditQuestion(null); refresh() }}
onClose={() => setEditQuestion(null)} />
)}
{creating && (
<CreateQuestionModal categories={categories}
onCreated={() => { setCreating(false); refresh() }}
@ -228,7 +224,11 @@ export default function QuestionManagerPage() {
</div>
</div>
<div className="qm-row-actions">
<button className="btn btn-secondary btn-sm" onClick={() => setEditQuestion(q)}>Edit</button>
{/* 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. */}
<Link className="btn btn-secondary btn-sm" to={`/questions/${q.id}`}
state={{ from: `${location.pathname}${location.search}`, label: 'Question manager' }}>Edit</Link>
{deletingId === q.id ? (
<>
<button className="btn btn-danger btn-sm" aria-label={`Confirm delete question ${q.id}`} onClick={() => deleteOne(q.id)}>Confirm</button>

View file

@ -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(<MemoryRouter><QuestionManagerPage /></MemoryRouter>)
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()
})