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:
parent
a1459b2965
commit
07eae7937b
3 changed files with 184 additions and 9 deletions
164
backend/scripts/article_pipeline.py
Normal file
164
backend/scripts/article_pipeline.py
Normal 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())
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
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 api from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors'
|
import { CreateQuestionModal } from '../components/QuestionEditors'
|
||||||
import GrantsPanel from '../components/GrantsPanel'
|
import GrantsPanel from '../components/GrantsPanel'
|
||||||
import './QuestionManagerPage.css'
|
import './QuestionManagerPage.css'
|
||||||
|
|
||||||
|
|
@ -48,7 +48,6 @@ export default function QuestionManagerPage() {
|
||||||
const [selected, setSelected] = useState(() => new Set())
|
const [selected, setSelected] = useState(() => new Set())
|
||||||
const [bulkBusy, setBulkBusy] = useState(false)
|
const [bulkBusy, setBulkBusy] = useState(false)
|
||||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
||||||
const [editQuestion, setEditQuestion] = useState(null)
|
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState(null)
|
const [deletingId, setDeletingId] = useState(null)
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
|
|
@ -125,14 +124,11 @@ export default function QuestionManagerPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1)
|
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 (
|
return (
|
||||||
<div className="qm-page">
|
<div className="qm-page">
|
||||||
{editQuestion && (
|
|
||||||
<QuestionEditModal question={editQuestion} categories={categories}
|
|
||||||
onSaved={() => { setEditQuestion(null); refresh() }}
|
|
||||||
onClose={() => setEditQuestion(null)} />
|
|
||||||
)}
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<CreateQuestionModal categories={categories}
|
<CreateQuestionModal categories={categories}
|
||||||
onCreated={() => { setCreating(false); refresh() }}
|
onCreated={() => { setCreating(false); refresh() }}
|
||||||
|
|
@ -228,7 +224,11 @@ export default function QuestionManagerPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="qm-row-actions">
|
<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 ? (
|
{deletingId === q.id ? (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-danger btn-sm" aria-label={`Confirm delete question ${q.id}`} onClick={() => deleteOne(q.id)}>Confirm</button>
|
<button className="btn btn-danger btn-sm" aria-label={`Confirm delete question ${q.id}`} onClick={() => deleteOne(q.id)}>Confirm</button>
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,14 @@ it('surfaces a server error from a bulk action', async () => {
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Share' }))
|
await userEvent.click(screen.getByRole('button', { name: 'Share' }))
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('at most 500 questions')
|
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()
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue