Skip to content

Understand-a-topic smart routing (grade → chapter → plan or read)

Status: shipped LNO: L. Decides what the single most-used learner intent actually does. It is the join between the chat surface, the study-plan product, and the B2C generation pool. Owners: @bhanu Last updated: 2026-07-13 Roles affected: B2C learners in grades 1-12 only. Every other learner (B2B, B2C outside 1-12) keeps the previous free-text chat flow, unchanged. Primary routes: /ai-tutor (the Understand picker), /studyplan/:planId (deep-link target), /studyplan/create (prefilled create) Source of the ask: owner-driven, shipped in PR #1312, corrected in PR #1315. Related: Learner Guided UX, B2C two-tier pricing

TL;DR

"Understand a topic" no longer drops the learner into a free-text box. For a B2C learner in grades 1-12 it now asks two questions (subject, then chapter) and routes on what the learner already owns:

  • Already has a study plan with this chapter → offer to open it, at the right chapter. No duplicate content.
  • No plan → offer two exits: build a study plan prefilled with this chapter, or take a standalone short read (about five minutes, no quiz) right there in the chat.

The short read is a real generation: POST /study-plans/chapter-read, grounded on the indexed textbook chapter when one exists. It is metered against the B2C free generation pool via the new QuotaFeature.CHAPTER_READ.

Why this exists

Who asked for it

Owner-driven (@bhanu), self-initiated. Flagged honestly: no support ticket, no interview transcript behind it.

What user pain does it solve

A learner picking "Understand a topic" got a subject chip and then a blank message bar. Two bad outcomes followed. A learner who already had a study plan for that chapter got a fresh, throwaway explanation from chat instead of the chapter sitting in their plan. The product duplicated itself and quietly told the learner their plan did not matter. A learner with no plan had to know how to phrase a good prompt to get anything useful.

Cost of not doing it

The study plan is the retention artefact. Every Understand answer that bypassed it trained learners to treat chat as the whole product and the plan as an optional side feature. Chat answers are also the cheapest thing to churn away from.

Validation

Weak / self-initiated. The duplication problem is structurally observable in the code (two generation paths over the same corpus chapter), not measured from user behaviour. Treat the conversion from "quick read" to "create plan" as the open question.

How we know it worked

A learner who has a plan opens it from the Understand intent instead of generating a parallel explanation, and short reads convert into plan creation at a rate worth the pool spend. Counterfactual: remove it and a learner with a plan lands back in a blank box, and would notice specifically that "it forgot I already have a plan for this".

Who gets it

Eligibility is one predicate, in one place (understandEligibility.ts:16-20): role is b2c_user and the onboarding profile has a class_level between 1 and 12. That is exactly the cohort the NCERT syllabus corpus and generated study plans cover.

UnderstandPicker is the only branch point. Eligible learners get UnderstandChapterFlow; everyone else gets UnderstandChatFlow (the original subject → materials → free-text flow) with no behaviour change. The intent tile itself never branches.

The flow

Understand a topic
Subject step  ──▶  Chapter step  (GET /study-plans/syllabus-topics)
                        ▼  learner confirms a chapter
                 GET /study-plans/lookup
        ┌───────────────┴────────────────┐
     match                            no match
        │                                │
        ▼                                ▼
 "Open your plan"              ┌─── Create a study plan  ──▶ /studyplan/create (prefilled)
 ──▶ /studyplan/:planId        ├─── Quick read (~5 min)  ──▶ POST /study-plans/chapter-read
     (right chapter)           └─── Not now              ──▶ back to the chapter picker

The five phases are derived, never stored (useUnderstandRouting.ts:42-53): chapterchecking → then one of existing-plan / create-prompt, with reading while a short read is in flight. A failed lookup is a toast, not a dead end: the phase resolves to create-prompt on its own, so the learner still gets both exits.

Existing plan. The lookup returns the plan id and the chapter sequence, so the deep link lands on the chapter, not the plan's front page. Matching is done backend-side against the learner's non-draft plans on subject plus either the skeleton's textbook chapter_id or, when that is missing, a case-insensitive chapter-title match (generated_study_plan.py:987-1005).

Quick read. The generated read is injected into the chat transcript as an assistant message carrying chapterRead: { title, blocks, grounded } and rendered with the same content-block renderer the study-plan chapter reader uses. It also seeds the follow-up context (subject, subject name, topic), so the learner's next free-text message is already grounded. When grounded is false, meaning no indexed textbook chapter backed the read, the message carries an explicit notice that the explanation came from general knowledge (MessageBubble.tsx:142-153).

Endpoints

Method Endpoint Purpose
GET /study-plans/syllabus-topics?subject=&class_level= Chapter options for the picker. An uncovered subject/class returns options: [], not an error.
GET /study-plans/lookup?subject=&chapter_id=&chapter_title= Does the learner already have this corpus chapter inside a plan? Returns { match: { planId, chapterSequence } | null }.
POST /study-plans/chapter-read Generate the standalone short read. Body: class_level, subject, chapter_id, optional chapter_title, optional attachment_ids. Returns { title, blocks, grounded }.

chapter_title is not decoration: chapter_id is an opaque corpus code (jesc102), so the agent needs the learner-facing label to write about the right thing when the corpus lookup cannot supply one. Passing it was the fix in PR #1315.

Quota

QuotaFeature.CHAPTER_READ is pool-metered, not Pro-locked (apps/backend/src/services/b2c_quota.py:79-81, and _POOL_FEATURES at lines 85-94). It sits with presentations, research, exam prep, recap, and image generation: a free B2C learner spends one unit of the shared monthly generation pool per short read. Study plans themselves stay Pro-locked.

The endpoint increments before generating (generated_study_plans.py:374) and refunds on every failure path: attachment-limit rejection and agent failure both call b2c_quota.refund(...) (lines 391 and 396). Pool exhaustion returns 429 with the quota detail; the client turns that into the QuotaExceededModal, and its upgrade CTA opens the B2C PaywallModal (UnderstandChapterFlow.tsx:63-73). A quota failure never looks like a broken read.

Non-goals

  • Routing for B2B learners or B2C learners outside grades 1-12. No corpus coverage, so no smart routing. They keep the old flow verbatim.
  • A quiz inside the short read. The read is deliberately quiz-free; assessment belongs to the plan and to Practice.
  • Persisting the short read. It lives in the chat transcript. If the learner wants something durable, the answer is a study plan. That is the whole point of the routing.

Rejected alternatives

  • Always generate a fresh read. Simplest, and it is what the old flow effectively did. Rejected: it duplicates content the learner already owns and devalues the plan.
  • Always push the learner into plan creation. Too heavy for "I just want to understand this one chapter tonight". The short read is the low-commitment exit that keeps the plan as the upsell.
  • Storing the routing phase in state. Derived from the lookup and mutation states instead, so a failed lookup cannot strand the learner in a phase that no longer applies.

Where it lives

  • Frontend: apps/web/src/pages/shared/AITutorPage/hooks/useUnderstandRouting.ts, and under components/IntentPickers/: UnderstandPicker.tsx (the eligibility branch), understandEligibility.ts, UnderstandChapterFlow.tsx, UnderstandRoutingResult.tsx, UnderstandChatFlow.tsx (the unchanged fallback).
  • Services: apps/web/src/services/generated-study-plan/hooks.ts (useSyllabusTopics, useChapterLookup, useChapterRead) and schemas.ts.
  • Backend: apps/backend/src/api/v1/generated_study_plans.py (/lookup, /chapter-read), apps/backend/src/services/generated_study_plan.py (find_plan_for_chapter, generate_chapter_read), apps/backend/src/schemas/generated_study_plan.py, apps/backend/src/services/b2c_quota.py (CHAPTER_READ).

Changelog

  • 2026-07-13: Doc created, retroactively. The flow shipped in PR #1312 (routing) and PR #1315 (textbook grounding restored, real chapter title passed through) with no doc; this captures it before the next change.