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 Three things live here because they are decisions rather than plumbing, and each
one has a failure mode worth naming. one has a failure mode worth naming.
**Variants.** One article is read three ways the full text, the key points, and **Variants.** One article is read three ways short, long, and the clinical view
the clinical view that carries management and doses. They are one article rather that carries management and doses. They are one article rather
than three because they describe one condition: split into three rows they drift 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 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. 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__) 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" DEFAULT_VARIANT = "long"
# [[7|Febrile seizures]] — id first, because the id is the part that must not # [[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, Typical headings: Definition, Epidemiology, Etiology, Pathophysiology,
Clinical features, Diagnostics, Differential diagnosis, Treatment, Clinical features, Diagnostics, Differential diagnosis, Treatment,
Complications, Prevention. Use only the ones the passages support. Complications, Prevention. Use only the ones the passages support.
2. "key_points" one section titled "Key points", 6 to 10 short bullets a 2. "short" one section titled "In short", 6 to 10 tight bullets a learner
learner could revise from the night before an exam. 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: 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 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 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", "summary": "one or two sentences, no heading",
"long": [{{"title": "Definition", "content": "markdown"}}], "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"}}] "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 = _unique_slugs([
*_sections(data.get("long"), "long"), *_sections(data.get("long"), "long"),
*_sections(data.get("key_points"), "key_points"), *_sections(data.get("short"), "short"),
*_sections(data.get("clinical"), "clinical"), *_sections(data.get("clinical"), "clinical"),
]) ])
if not sections: 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 # 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. # the shortlist with nothing — a topic named after a shelf retrieves ten of them.
MIN_PROSE_CHARS = 200 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 TIMEOUT = 20
@ -119,7 +124,7 @@ def search(query: str, limit: int = DEFAULT_LIMIT,
"collectionName": settings.CLINICAL_MILVUS_COLLECTION, "collectionName": settings.CLINICAL_MILVUS_COLLECTION,
"data": [vector], "data": [vector],
"annsField": "dense", "annsField": "dense",
"limit": limit, "limit": limit * POOL_MULTIPLIER,
"outputFields": ["payload", "folder_path", "file_path", "chunk_index"], "outputFields": ["payload", "folder_path", "file_path", "chunk_index"],
} }
# Filtering in the query is what keeps the shelf constraint honest: taking # 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), "source": _source_of(raw),
"folder": _tidy(hit.get("folder_path")), "folder": _tidy(hit.get("folder_path")),
}) })
if len(results) >= limit:
break
return results 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 * links and the pane keeps it to itself; everything else what is open, the
* rail, the mobile drawer belongs to this reader alone. * rail, the mobile drawer belongs to this reader alone.
*/ */
// One topic, three readings. The full article is what you study from; the key // One topic, three readings. Short is what you revise from, long is what you
// points are what you revise from; the clinical view is what you act from at the // study from, clinical is what you act from at the bedside. They are views of
// bedside. They are views of one article rather than three articles, so the // one article rather than three articles, so the numbers cannot drift apart and
// numbers cannot drift apart and a question linked to the topic still means one // a question linked to the topic still means one thing.
// 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 = [ const VIEWS = [
{ key: 'long', label: 'Article' }, { key: 'short', label: 'Short' },
{ key: 'key_points', label: 'Key points' }, { key: 'long', label: 'Long' },
{ key: 'clinical', label: 'Clinical' }, { key: 'clinical', label: 'Clinical' },
] ]

View file

@ -14,7 +14,7 @@ const queue = {
], ],
machine_drafts: [ machine_drafts: [
{ id: 2, slug: 'intussusception', title: 'Intussusception', status: 'draft', { 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_references: [],
published_without_questions: [ published_without_questions: [