DocDoeAI / app /services /concept_graph.py
asnannp's picture
Deploy backend cd4237ff: support routes + rate limit + exam_date nullable + upload 413 fix
7c6ffa6
Raw
History Blame Contribute Delete
11.5 kB
"""Concept-level learning graph.
Data-driven registry of teachable concepts, keyed by the SAME ``topic_key``
convention the product already persists mastery under:
``"{chapter_catalog_id}:{mission_id}"`` (see ``topic_mastery.topic_key`` and
GuidedClass's assessment sync). For v1 each curated Sound Waves mission is one
concept node; the shape is chapter-agnostic so later chapters extend the data,
not the code.
Honesty rules encoded here:
- Only Sound Waves (``phy-p1-c1``) is a complete curated class; its nodes carry
real objectives/misconceptions lifted from the hand-authored, source-backed
lesson seeds (``src/lib/teaching/physicsLessonSeeds.ts``).
- ``pyq`` resources are ``False`` everywhere because no PYQ evidence is curated
yet (``pyqEvidenceStatus: "not_curated"`` in the seeds).
- Unknown chapters/missions simply are not in the graph β€” callers must treat
absence as "content unavailable", never invent coverage.
"""
from __future__ import annotations
from dataclasses import dataclass, field
SOUND_WAVES_CHAPTER_ID = "phy-p1-c1"
@dataclass(frozen=True)
class ConceptResources:
lesson: bool = False
quiz: bool = False
flashcards: bool = False
board_answer: bool = False
numericals: bool = False
pyq: bool = False
@dataclass(frozen=True)
class ConceptNode:
concept_key: str # "{chapter_catalog_id}:{mission_id}"
chapter_catalog_id: str
mission_id: str
subject: str
title: str
objective: str
prerequisites: tuple[str, ...] # concept_keys
exam_importance: float # 0..1, relative weight inside the chapter
typical_misconceptions: tuple[str, ...]
practice_types: tuple[str, ...]
estimated_minutes: int
resources: ConceptResources = field(default_factory=ConceptResources)
availability: str = "curated" # curated | notes_only | locked
textbook_reference: str = ""
def _sw(mission_id: str) -> str:
return f"{SOUND_WAVES_CHAPTER_ID}:{mission_id}"
_SW_REFERENCE = "Kerala SCERT Physics, Standard X (2025) β€” Sound Waves, pp. 7-26"
_SW_RESOURCES = ConceptResources(lesson=True, quiz=True, flashcards=True, board_answer=True, pyq=False)
_SW_NUMERIC_RESOURCES = ConceptResources(lesson=True, quiz=True, flashcards=True, board_answer=True, numericals=True, pyq=False)
SOUND_WAVES_CONCEPTS: tuple[ConceptNode, ...] = (
ConceptNode(
concept_key=_sw("M1"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M1",
subject="Physics",
title="Oscillation, Amplitude, Period and Frequency",
objective="Understand the basic terms of oscillatory motion using a pendulum and swing.",
prerequisites=(),
exam_importance=0.8,
typical_misconceptions=(
"One oscillation is just going forward β€” it must return to complete one.",
),
practice_types=("recall", "mcq", "board_answer"),
estimated_minutes=25,
resources=_SW_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M2"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M2",
subject="Physics",
title="Natural Frequency, Forced Vibration & Resonance",
objective="Understand natural frequency and how forced vibrations lead to resonance.",
prerequisites=(_sw("M1"),),
exam_importance=0.7,
typical_misconceptions=(
"Not recognising that the table is forced to vibrate by the mixie (forced vibration).",
"Forgetting that resonance needs matching frequencies AND produces maximum amplitude.",
),
practice_types=("recall", "mcq", "board_answer"),
estimated_minutes=25,
resources=_SW_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M3"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M3",
subject="Physics",
title="Wave Motion & Types of Waves",
objective="Learn how waves transfer energy without transferring particles, and differentiate longitudinal and transverse waves.",
prerequisites=(_sw("M1"),),
exam_importance=0.85,
typical_misconceptions=(
"Thinking the material itself moves forward with the wave β€” only energy moves.",
),
practice_types=("recall", "mcq", "diagram", "board_answer"),
estimated_minutes=30,
resources=_SW_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M4"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M4",
subject="Physics",
title="Frequency, Wavelength and Wave Speed (v = fΞ»)",
objective="Define wavelength, speed of a wave, and understand the relationship v = fΞ».",
prerequisites=(_sw("M3"),),
exam_importance=1.0,
typical_misconceptions=(
"Confusing wavelength with amplitude.",
),
practice_types=("recall", "mcq", "formula", "board_answer"),
estimated_minutes=30,
resources=_SW_NUMERIC_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M5"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M5",
subject="Physics",
title="Numericals using v = fΞ»",
objective="Solve exam-style numericals step-by-step using v = fΞ».",
prerequisites=(_sw("M4"),),
exam_importance=1.0,
typical_misconceptions=(
"Substituting wavelength in centimetres without converting to metres.",
),
practice_types=("numerical", "board_answer"),
estimated_minutes=30,
resources=_SW_NUMERIC_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M6"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M6",
subject="Physics",
title="Reflection, Echo & Reverberation",
objective="Understand how sound reflects, the conditions for an echo, and reverberation.",
prerequisites=(_sw("M3"),),
exam_importance=0.9,
typical_misconceptions=(
"Thinking reflection doesn't happen in a small room β€” it does, we just cannot distinguish the echo.",
),
practice_types=("recall", "mcq", "numerical", "board_answer"),
estimated_minutes=30,
resources=_SW_NUMERIC_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M7"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M7",
subject="Physics",
title="Limits of Audibility & Ultrasonic Uses",
objective="Understand audible range, infrasonic and ultrasonic sounds, and their applications.",
prerequisites=(_sw("M4"),),
exam_importance=0.7,
typical_misconceptions=(
"Assuming humans can hear everything β€” dogs and bats hear higher frequencies than us.",
),
practice_types=("recall", "mcq", "board_answer"),
estimated_minutes=25,
resources=_SW_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M8"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M8",
subject="Physics",
title="Seismic Waves and Tsunami",
objective="Understand the destructive power of seismic waves and tsunamis.",
prerequisites=(_sw("M3"),),
exam_importance=0.6,
typical_misconceptions=(
"Waves are not just sound or light β€” they can carry massive destructive energy.",
),
practice_types=("recall", "mcq", "board_answer"),
estimated_minutes=25,
resources=_SW_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
ConceptNode(
concept_key=_sw("M9"),
chapter_catalog_id=SOUND_WAVES_CHAPTER_ID,
mission_id="M9",
subject="Physics",
title="Sound Waves Chapter Test & Revision",
objective="Complete a source-backed final chapter test and turn remaining mistakes into targeted revision.",
prerequisites=tuple(_sw(f"M{index}") for index in range(1, 9)),
exam_importance=1.0,
typical_misconceptions=(
"Knowing isolated definitions without connecting the formula, condition and application.",
),
practice_types=("revision", "chapter_test", "numerical", "board_answer"),
estimated_minutes=40,
resources=_SW_NUMERIC_RESOURCES,
textbook_reference=_SW_REFERENCE,
),
)
_CONCEPTS_BY_KEY: dict[str, ConceptNode] = {node.concept_key: node for node in SOUND_WAVES_CONCEPTS}
_CONCEPTS_BY_CHAPTER: dict[str, tuple[ConceptNode, ...]] = {SOUND_WAVES_CHAPTER_ID: SOUND_WAVES_CONCEPTS}
def concept_for(topic_key: str) -> ConceptNode | None:
return _CONCEPTS_BY_KEY.get(topic_key)
def concepts_for_chapter(chapter_catalog_id: str) -> tuple[ConceptNode, ...]:
return _CONCEPTS_BY_CHAPTER.get(chapter_catalog_id, ())
def all_concepts() -> tuple[ConceptNode, ...]:
return SOUND_WAVES_CONCEPTS
def prerequisites_met(concept: ConceptNode, secure_or_developing_keys: set[str]) -> bool:
"""A concept may be scheduled as NEW learning only when every prerequisite
has at least been introduced/learned (present in the provided key set)."""
return all(prerequisite in secure_or_developing_keys for prerequisite in concept.prerequisites)
def ordered_chapter_concepts(chapter_catalog_id: str) -> list[ConceptNode]:
"""Concepts in a safe teaching order (prerequisites before dependants).
The seed data is already authored in teaching order (M1..M9); this sorts
defensively anyway so data edits cannot silently break ordering.
"""
nodes = list(concepts_for_chapter(chapter_catalog_id))
placed: list[ConceptNode] = []
placed_keys: set[str] = set()
remaining = nodes[:]
while remaining:
progressed = False
for node in list(remaining):
if all(prerequisite in placed_keys for prerequisite in node.prerequisites):
placed.append(node)
placed_keys.add(node.concept_key)
remaining.remove(node)
progressed = True
if not progressed: # cycle in data β€” fail safe by appending as-authored
placed.extend(remaining)
break
return placed
def concept_to_dict(node: ConceptNode) -> dict[str, object]:
return {
"concept_key": node.concept_key,
"chapter_catalog_id": node.chapter_catalog_id,
"mission_id": node.mission_id,
"subject": node.subject,
"title": node.title,
"objective": node.objective,
"prerequisites": list(node.prerequisites),
"exam_importance": node.exam_importance,
"typical_misconceptions": list(node.typical_misconceptions),
"practice_types": list(node.practice_types),
"estimated_minutes": node.estimated_minutes,
"resources": {
"lesson": node.resources.lesson,
"quiz": node.resources.quiz,
"flashcards": node.resources.flashcards,
"board_answer": node.resources.board_answer,
"numericals": node.resources.numericals,
"pyq": node.resources.pyq,
},
"availability": node.availability,
"textbook_reference": node.textbook_reference,
}