feat: short / long / clinical, and stop broad topics retrieving index lines

Renames the middle view to Short and puts it first: it is the quickest way to
tell whether this is the article you wanted, and the full text is one click
away. Existing generated articles were migrated in place.

The prompt now asks for bullets that each carry a fact, because "X is important
to recognise" is a bullet that survives revision and teaches nothing.

The retrieval bug that made the last run mostly skips
The prose filter — drop chunks under 200 characters, since they are headings and
index lines — ran *after* taking the top fourteen hits. A broad query like
"Immunodeficiency" or a specialty name matches chapter titles first, so all
fourteen were index lines and the filter left nothing: the topic was skipped as
having no source material when the library holds plenty. Retrieval now asks for
five times what it needs and keeps the first passages that are actually prose.
Immunodeficiency went from 0 passages to 14, Pediatric Cardiology 0 to 14.

That is the same mistake the folder filter has a comment warning about — filter
inside the ranking, not after it — made two functions later.

Two things I got wrong and corrected rather than worked around: a `LIKE
'%key_points%'` check reported the migration had failed, when `_` is a
single-character wildcard and it was matching the title "Key points"; and a
variant count showing no Short sections was taken against the old image, where
Short was not yet a known variant and was being coerced to Long.

203 backend, 234 frontend 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-10 18:21:21 +02:00
parent 3980ecb7f8
commit e1b580386c
5 changed files with 29 additions and 16 deletions

View file

@ -3,8 +3,8 @@
Three things live here because they are decisions rather than plumbing, and each
one has a failure mode worth naming.
**Variants.** One article is read three ways the full text, the key points, and
the clinical view that carries management and doses. They are one article rather
**Variants.** One article is read three ways short, long, and the clinical view
that carries management and doses. They are one article rather
than three because they describe one condition: split into three rows they drift
apart, and a question linked to "Bronchiolitis" would have to pick which of the
three it meant. Each section carries the variant it belongs to.
@ -28,7 +28,10 @@ from app.models.article import Article, ArticleRevision, ArticleSlug
logger = logging.getLogger(__name__)
VARIANTS = ("long", "key_points", "clinical")
# Short first: it is the fastest way to tell whether this is the article you
# wanted. Long is one click away and is what an article written before variants
# existed becomes, since that is what it was.
VARIANTS = ("short", "long", "clinical")
DEFAULT_VARIANT = "long"
# [[7|Febrile seizures]] — id first, because the id is the part that must not

View file

@ -66,8 +66,9 @@ Produce three views of the same topic:
Typical headings: Definition, Epidemiology, Etiology, Pathophysiology,
Clinical features, Diagnostics, Differential diagnosis, Treatment,
Complications, Prevention. Use only the ones the passages support.
2. "key_points" one section titled "Key points", 6 to 10 short bullets a
learner could revise from the night before an exam.
2. "short" one section titled "In short", 6 to 10 tight bullets a learner
could revise from the night before an exam. Every bullet must carry a fact:
no bullet that only says a topic is important.
3. "clinical" one or two sections covering management at the bedside:
what to do, in what order, with drug doses and routes where the passages give
them. Include units and per-kilogram dosing exactly as stated. If a dose is
@ -78,7 +79,7 @@ Return ONLY valid JSON, no markdown fence:
{{
"summary": "one or two sentences, no heading",
"long": [{{"title": "Definition", "content": "markdown"}}],
"key_points": [{{"title": "Key points", "content": "- bullet\\n- bullet"}}],
"short": [{{"title": "In short", "content": "- bullet\\n- bullet"}}],
"clinical": [{{"title": "Management", "content": "markdown"}}]
}}
@ -161,7 +162,7 @@ def write_article(db: Session, topic: str, category_id: int | None = None,
sections = _unique_slugs([
*_sections(data.get("long"), "long"),
*_sections(data.get("key_points"), "key_points"),
*_sections(data.get("short"), "short"),
*_sections(data.get("clinical"), "clinical"),
])
if not sections:

View file

@ -34,6 +34,11 @@ DEFAULT_LIMIT = 12
# They rank well against a query that looks like a chapter title and then fill
# the shortlist with nothing — a topic named after a shelf retrieves ten of them.
MIN_PROSE_CHARS = 200
# Ask for more than we need, because the prose filter runs after ranking. A
# broad query — "Immunodeficiency", a specialty name — matches chapter titles
# and index lines first, and filtering inside a shortlist of fourteen left
# nothing at all.
POOL_MULTIPLIER = 5
TIMEOUT = 20
@ -119,7 +124,7 @@ def search(query: str, limit: int = DEFAULT_LIMIT,
"collectionName": settings.CLINICAL_MILVUS_COLLECTION,
"data": [vector],
"annsField": "dense",
"limit": limit,
"limit": limit * POOL_MULTIPLIER,
"outputFields": ["payload", "folder_path", "file_path", "chunk_index"],
}
# Filtering in the query is what keeps the shelf constraint honest: taking
@ -150,6 +155,8 @@ def search(query: str, limit: int = DEFAULT_LIMIT,
"source": _source_of(raw),
"folder": _tidy(hit.get("folder_path")),
})
if len(results) >= limit:
break
return results

View file

@ -46,14 +46,16 @@ export function Markdown({ children, attemptId }) {
* links and the pane keeps it to itself; everything else what is open, the
* rail, the mobile drawer belongs to this reader alone.
*/
// One topic, three readings. The full article is what you study from; the key
// points are what you revise from; the clinical view is what you act from at the
// bedside. They are views of one article rather than three articles, so the
// numbers cannot drift apart and a question linked to the topic still means one
// thing.
// One topic, three readings. Short is what you revise from, long is what you
// study from, clinical is what you act from at the bedside. They are views of
// one article rather than three articles, so the numbers cannot drift apart and
// a question linked to the topic still means one thing.
//
// Short leads because it is the quickest way to tell whether this is the article
// you wanted; the full text is one click away.
const VIEWS = [
{ key: 'long', label: 'Article' },
{ key: 'key_points', label: 'Key points' },
{ key: 'short', label: 'Short' },
{ key: 'long', label: 'Long' },
{ key: 'clinical', label: 'Clinical' },
]

View file

@ -14,7 +14,7 @@ const queue = {
],
machine_drafts: [
{ id: 2, slug: 'intussusception', title: 'Intussusception', status: 'draft',
generated_by: 'clinical-library:claude', variants: ['long', 'key_points', 'clinical'] },
generated_by: 'clinical-library:claude', variants: ['short', 'long', 'clinical'] },
],
published_without_references: [],
published_without_questions: [