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 { 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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue