diff --git a/examples/curriculum_courses/content.json b/examples/curriculum_courses/content.json new file mode 100644 index 00000000..18c8b907 --- /dev/null +++ b/examples/curriculum_courses/content.json @@ -0,0 +1,444 @@ +{ + "courses": [ + { + "source_id": "hummingbird-biology", + "title": "Hummingbird Biology", + "description": "Learn about the fascinating biology of hummingbirds", + "units": [ + { + "source_id": "hb-anatomy-physiology", + "title": "Anatomy & Physiology", + "description": "Explore the unique anatomical features of hummingbirds", + "learning_objectives": [ + "Identify the key bones in a hummingbird skeleton", + "Explain how skeletal adaptations enable hovering flight", + "Describe the major muscle groups used in flight", + "Explain the role of fast-twitch muscles in wing movement", + "Describe how the respiratory system supports high metabolism", + "Explain the function of air sacs in hummingbird respiration" + ], + "lessons": [ + { + "source_id": "hb-skeletal-structure", + "title": "Skeletal Structure", + "learning_objective_indices": [0, 1], + "content": "

Hummingbirds possess one of nature's most remarkable skeletal systems, evolved over millions of years to support their unique flight capabilities. Unlike most birds, whose skeletons account for about 5% of body weight, hummingbird bones are extraordinarily lightweight while maintaining exceptional strength.

Hummingbird Skeletal AdaptationsEnlarged KeelSkullBall-SocketShoulderFused VertebraeReduced Leg Bones

Bone Structure

The secret lies in their pneumatized bones - hollow structures reinforced with internal struts, similar to the engineering principles used in aircraft wings. This honeycomb-like internal architecture reduces weight by up to 50% compared to solid bone while retaining structural integrity.

Did You Know? A Ruby-throated Hummingbird's entire skeleton weighs less than a paperclip - about 0.9 grams! Yet these delicate bones withstand G-forces that would shatter human bones.

Key Skeletal Features

The hummingbird skeleton includes several specialized adaptations:

Species Spotlight: Bee Hummingbird
The world's smallest bird, Cuba's Bee Hummingbird (Mellisuga helenae), has a skeleton so tiny that its entire body mass is just 1.6-2 grams - lighter than a US penny. Its femur (thigh bone) is about 5mm long, yet perfectly formed for perching.

Evolutionary Significance

These skeletal adaptations represent millions of years of evolutionary refinement. Each feature works in concert with the others: the fused vertebrae wouldn't be useful without the flexible shoulders, and the enlarged keel would be unnecessary without the powerful muscles it supports.

Human Comparison: If humans had proportionally sized keels like hummingbirds, our breastbones would extend outward about 30 cm (1 foot) from our chests! This would be necessary to anchor flight muscles weighing over 40 pounds on each side.
", + "exercise_questions": [ + { + "type": "single_select", + "id": "hb-skel-q1", + "question": "A biologist discovers a new bird species with solid, dense bones. Based on what you learned about hummingbird skeletal adaptations, what flight behavior would this bird likely be UNABLE to perform?", + "correct_answer": "Sustained hovering in place", + "all_answers": ["Sustained hovering in place", "Perching on branches", "Walking on the ground", "Sleeping while perched"] + }, + { + "type": "multiple_select", + "id": "hb-skel-q2", + "question": "An engineer is designing a micro-drone inspired by hummingbirds. Which skeletal features should they incorporate into their design for hovering capability?", + "correct_answers": ["A rigid central frame like fused vertebrae", "Rotating joints allowing 180-degree wing rotation"], + "all_answers": ["A rigid central frame like fused vertebrae", "Rotating joints allowing 180-degree wing rotation", "Heavy landing gear for stability", "Flexible backbone for extra maneuverability"] + }, + { + "type": "single_select", + "id": "hb-skel-q3", + "question": "A Bee Hummingbird weighing 2 grams has flight muscles comprising 30% of its body weight. If its keel bone were reduced to the size of a typical songbird's (proportionally), what problem would the hummingbird face?", + "correct_answer": "Insufficient anchor points for powerful flight muscles", + "all_answers": ["Insufficient anchor points for powerful flight muscles", "Inability to digest nectar", "Loss of body temperature regulation", "Reduced vision capabilities"] + } + ] + }, + { + "source_id": "hb-muscle-systems", + "title": "Muscle Systems", + "learning_objective_indices": [2, 3], + "content": "

The muscular system of hummingbirds is a marvel of biological engineering, representing the most extreme adaptation for sustained powered flight in the animal kingdom. These tiny birds possess muscles that can contract and relax at frequencies that would cause fatigue within seconds in other animals.

Muscle Pulley SystemPectoralis Major(Downstroke)Supracoracoideus(Upstroke)Triosseal Canal(Pulley Point)TendonWingWingThe tendon acts like a rope through a pulley,allowing the lower muscle to lift the wing25-30% body wt~10% body wtOther: ~60%

Flight Muscle Composition

Hummingbird flight muscles comprise an astounding 25-30% of total body weight - the highest proportion of any bird species. By comparison, human leg muscles (our primary locomotion muscles) make up only about 20% of body weight. This massive investment in flight capability comes at the cost of leg strength; hummingbirds can barely walk and instead shuffle sideways on their perches.

Did You Know? The supracoracoideus pulley system is unique to birds with powered flight. It's so efficient that hummingbird muscles can contract and relax completely in just 0.01 seconds - that's 100 times per second!

The Two Primary Flight Muscles

Two muscles dominate the hummingbird's flight system:

Species Spotlight: Anna's Hummingbird
The Anna's Hummingbird (Calypte anna) of western North America has been clocked with wing beats up to 80 times per second during normal hovering. During courtship dives, males reach speeds of 385 body lengths per second - the highest length-specific velocity of any vertebrate!

Fast-Twitch Fiber Dominance

What makes hummingbird muscles truly extraordinary is their composition. They are composed almost entirely of fast-twitch muscle fibers - the same type that powers a sprinter's explosive speed. However, unlike in humans where fast-twitch fibers fatigue quickly, hummingbird fast-twitch muscles have been modified to resist fatigue through:

This allows wing beats of 50-80 times per second in normal flight, and up to 200 beats per second during courtship dives - frequencies that would instantly fatigue any other vertebrate muscle.

Human Comparison: If a human sprinter's muscles worked like hummingbird muscles, they could sprint at full speed continuously for hours without tiring. Our fast-twitch fibers deplete their energy in about 10-15 seconds, while hummingbird fast-twitch fibers can work indefinitely thanks to their enhanced oxygen delivery system.
", + "exercise_questions": [ + { + "type": "single_select", + "id": "hb-musc-q1", + "question": "A researcher is studying a 4-gram hummingbird. If they weigh just the flight muscles, approximately what mass should they expect?", + "correct_answer": "1.0-1.2 grams (25-30% of body weight)", + "all_answers": ["0.2-0.4 grams (5-10%)", "0.6-0.8 grams (15-20%)", "1.0-1.2 grams (25-30% of body weight)", "1.6-2.0 grams (40-50%)"] + }, + { + "type": "single_select", + "id": "hb-musc-q2", + "question": "A veterinarian examining an injured hummingbird notices damage to the tendon passing through the triosseal canal. Which flight motion would be most affected?", + "correct_answer": "The upstroke (wing lifting)", + "all_answers": ["The downstroke (wing pushing down)", "The upstroke (wing lifting)", "Forward flight only", "Perching ability"] + }, + { + "type": "single_select", + "id": "hb-musc-q3", + "question": "Scientists want to create artificial muscles for a hovering drone. Which cellular feature of hummingbird muscles would be MOST critical to replicate?", + "correct_answer": "High mitochondria density for continuous energy", + "all_answers": ["Large cell size for more power", "High mitochondria density for continuous energy", "Low oxygen requirements", "Slow contraction speed"] + } + ] + }, + { + "source_id": "hb-respiratory-system", + "title": "Respiratory System", + "learning_objective_indices": [4, 5], + "content": "

To power their extraordinary metabolism, hummingbirds have evolved the most efficient respiratory system of any vertebrate. Their oxygen demands during flight are so extreme that a conventional bird respiratory system would be completely inadequate.

Air Sac System & Unidirectional FlowLungs9 Air Sacs distributed throughout body● Fresh air IN(through posterior sacs)● Used air OUT(through anterior sacs)Air flows ONE WAY through lungs(during BOTH inhale and exhale)

Metabolic Demands

During hovering flight, a hummingbird's metabolic rate increases to approximately 10 times its resting rate. If a human scaled up to this level of exertion, it would be equivalent to running multiple marathons simultaneously. This demands an oxygen delivery system of unparalleled efficiency.

Did You Know? Hummingbird lungs extract oxygen so efficiently that they can thrive at altitudes of 14,000+ feet in the Andes mountains, where humans struggle to breathe. The Hillstar Hummingbird lives permanently at elevations that would give most people severe altitude sickness!

The Air Sac System

Like all birds, hummingbirds possess air sacs - thin-walled extensions of the respiratory system that penetrate throughout the body. However, hummingbirds have optimized this system to its extreme:

Species Spotlight: Giant Hummingbird
The Giant Hummingbird (Patagona gigas) of South America is the largest hummingbird at 20-24 grams - about the weight of 8 pennies. Despite its \"giant\" size, its respiratory system is so efficient that it can hover at 15,000 feet in the Andes, feeding on high-altitude flowers where the oxygen concentration is only 60% of sea level.

Breathing Rate

At rest, hummingbirds breathe about 250 times per minute - already faster than most small mammals. During flight, this can increase to over 500 breaths per minute. Each breath is shallow but extremely efficient, rapidly cycling fresh air through the system.

Integration with Flight

The respiratory and flight systems work in perfect synchrony. The pumping action of the flight muscles actually assists in moving air through the air sac system, creating a beautiful example of biological integration where two systems enhance each other's performance.

Human Comparison: If humans had hummingbird-style lungs, we could hold our breath for over an hour! Our tidal breathing (air in, air out) wastes about 30% of each breath as \"dead space\" - air that never reaches the gas exchange surfaces. Hummingbird unidirectional flow eliminates this inefficiency entirely.
", + "exercise_questions": [ + { + "type": "single_select", + "id": "hb-resp-q1", + "question": "A biologist is dissecting a hummingbird to study its respiratory anatomy. How many distinct air sac structures should they expect to find throughout the body cavity?", + "correct_answer": "Nine air sacs", + "all_answers": ["Two air sacs", "Five air sacs", "Nine air sacs", "Twelve air sacs"] + }, + { + "type": "multiple_select", + "id": "hb-resp-q2", + "question": "Engineers designing a high-efficiency ventilation system are studying hummingbird lungs for inspiration. Which features should they replicate?", + "correct_answers": ["One-way airflow through the exchange area", "Perpendicular flow between air and fluid for maximum transfer"], + "all_answers": ["One-way airflow through the exchange area", "Perpendicular flow between air and fluid for maximum transfer", "Single storage chamber", "Back-and-forth tidal flow"] + }, + { + "type": "single_select", + "id": "hb-resp-q3", + "question": "A Hillstar Hummingbird thrives at 14,000 feet elevation where oxygen is scarce. What primarily enables it to survive where humans would struggle?", + "correct_answer": "Continuous airflow through lungs on both inhale and exhale", + "all_answers": ["Continuous airflow through lungs on both inhale and exhale", "Unusually large lungs", "Very slow breathing to conserve oxygen", "Ability to hold breath for long periods"] + } + ] + } + ], + "prepost_questions": [ + {"id": "hb-anat-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "Which bone is enlarged in hummingbirds for muscle attachment?", "correct_answer": "Keel bone (sternum)", "all_answers": ["Keel bone (sternum)", "Femur", "Skull", "Pelvis"]}, + {"id": "hb-anat-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "What type of bones do hummingbirds have to reduce weight?", "correct_answer": "Hollow pneumatized bones", "all_answers": ["Hollow pneumatized bones", "Dense solid bones", "Cartilage only", "No bones"]}, + {"id": "hb-anat-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "How do fused vertebrae help hummingbirds?", "correct_answer": "Provide stability during hovering", "all_answers": ["Provide stability during hovering", "Allow greater flexibility", "Store more fat", "Produce more blood"]}, + {"id": "hb-anat-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What allows hummingbird wings to rotate 180 degrees?", "correct_answer": "Ball-and-socket shoulder joints", "all_answers": ["Ball-and-socket shoulder joints", "Extra wing bones", "Longer feathers", "Stronger muscles only"]}, + {"id": "hb-anat-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "Which muscle powers the downstroke in hummingbird flight?", "correct_answer": "Pectoralis major", "all_answers": ["Pectoralis major", "Supracoracoideus", "Biceps", "Deltoid"]}, + {"id": "hb-anat-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "Which muscle uses a pulley system to power the upstroke?", "correct_answer": "Supracoracoideus", "all_answers": ["Supracoracoideus", "Pectoralis major", "Triceps", "Trapezius"]}, + {"id": "hb-anat-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "How many times per second can hummingbird wings beat?", "correct_answer": "50-80 times", "all_answers": ["10-20 times", "30-40 times", "50-80 times", "100+ times normally"]}, + {"id": "hb-anat-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "What prevents hummingbird fast-twitch muscles from fatiguing?", "correct_answer": "High mitochondria density and rich capillaries", "all_answers": ["High mitochondria density and rich capillaries", "Slow contraction speed", "Low oxygen demand", "Rest between beats"]}, + {"id": "hb-anat-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "How many breaths per minute during flight?", "correct_answer": "Over 500", "all_answers": ["50", "150", "250", "Over 500"]}, + {"id": "hb-anat-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "Why do hummingbirds need such efficient respiration?", "correct_answer": "Metabolic rate increases 10x during flight", "all_answers": ["Metabolic rate increases 10x during flight", "They live at high altitudes only", "They are very large", "They breathe underwater"]}, + {"id": "hb-anat-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "What do air sacs provide in the respiratory system?", "correct_answer": "Continuous unidirectional airflow", "all_answers": ["Continuous unidirectional airflow", "Blood storage", "Food digestion", "Waste removal"]}, + {"id": "hb-anat-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "How does cross-current gas exchange benefit hummingbirds?", "correct_answer": "Up to 10x more efficient oxygen extraction", "all_answers": ["Up to 10x more efficient oxygen extraction", "Faster digestion", "Better hearing", "Stronger bones"]} + ] + }, + { + "source_id": "hb-feeding-metabolism", + "title": "Feeding & Metabolism", + "description": "Understand how hummingbirds fuel their high-energy lifestyle", + "learning_objectives": [ + "Identify the primary food sources for hummingbirds", + "Explain the role of nectar in hummingbird diet", + "Describe the digestive adaptations of hummingbirds", + "Explain how hummingbirds process sugars efficiently", + "Describe torpor and its role in energy conservation", + "Explain the metabolic rate differences between active and resting states" + ], + "lessons": [ + { + "source_id": "hb-diet-foraging", + "title": "Diet & Foraging", + "learning_objective_indices": [0, 1], + "content": "

Hummingbirds are specialized nectarivores, having evolved in close partnership with flowering plants over millions of years. This mutualistic relationship has shaped both their behavior and physiology in remarkable ways.

Hummingbird Feeding AdaptationsCurved Bill(species-specific)Forked Tongue15-20 licks/secFlowerNectarDaily DietNectar 90%Insects 10%Daily Visits1,000-2,000flowers per day

Primary Food Sources

The hummingbird diet consists of two main components:

Did You Know? Hummingbirds have exceptional spatial memory. They remember the exact location of every flower they've visited, when they visited it, and how long each flower takes to refill with nectar. Some researchers call this \"episodic-like memory\" - previously thought to be unique to humans!

Foraging Behavior

Hummingbirds are highly territorial feeders. A single bird may defend a patch of flowers aggressively, investing significant energy in chasing away competitors. This makes sense when you consider that they must consume roughly half their body weight in nectar each day - reliable food sources are worth fighting for.

Species Spotlight: Sword-billed Hummingbird
The Sword-billed Hummingbird (Ensifera ensifera) of the Andes has a bill longer than its body (excluding tail) - up to 10cm! This extreme adaptation allows it to feed from deep tubular flowers like Passiflora mixta that no other hummingbird can access. It's the only bird that must preen with its feet because its bill is too long to reach its feathers.

Physical Adaptations for Feeding

Evolution has equipped hummingbirds with specialized feeding apparatus:

Human Comparison: If a human had to eat proportionally as much as a hummingbird, we'd need to consume about 150,000 calories per day - roughly 300 Big Macs! And we'd need to do it by visiting tens of thousands of individual food sources, remembering exactly when we last visited each one.
", + "exercise_questions": [ + {"type": "single_select", "id": "hb-diet-q1", "question": "A wildlife researcher is setting up hummingbird feeders in a garden. To match the feeding intensity of wild foraging, approximately how many flower-equivalent feeding opportunities should she provide per hummingbird per day?", "correct_answer": "1,000-2,000 feeding opportunities", "all_answers": ["50-100 feeding opportunities", "200-500 feeding opportunities", "1,000-2,000 feeding opportunities", "5,000+ feeding opportunities"]}, + {"type": "multiple_select", "id": "hb-diet-q2", "question": "A hummingbird rehabilitation center needs to create a complete diet for recovering birds. Which food sources must they provide?", "correct_answers": ["Sugar water or nectar substitute", "Small insects like fruit flies"], "all_answers": ["Sugar water or nectar substitute", "Small insects like fruit flies", "Seeds and grains", "Leafy vegetables"]}, + {"type": "single_select", "id": "hb-diet-q3", "question": "A female hummingbird is raising chicks and needs extra protein. Why would she increase her insect hunting rather than simply drinking more nectar?", "correct_answer": "Nectar provides only sugar - insects provide essential proteins for growth", "all_answers": ["Nectar provides only sugar - insects provide essential proteins for growth", "Insects taste better to baby hummingbirds", "Insects are easier to catch than finding flowers", "Nectar has too much protein already"]} + ] + }, + { + "source_id": "hb-digestive-system", + "title": "Digestive System", + "learning_objective_indices": [2, 3], + "content": "

The hummingbird digestive system is a masterpiece of biological optimization, designed for one primary purpose: extracting and processing sugars with maximum speed and efficiency.

Streamlined Digestive SystemMost Birds:Large CropFood storageIntestineHummingbirds:TinyMinimal storageHighly EfficientIntestineHigh sucraseMax surface areaSpeed15-20minutesnectar → energy(humans: 2+ hours)Water Challenge: Nectar = 80% waterKidneys filter body weight in water DAILY

Streamlined for Speed

Unlike most birds, hummingbirds have virtually eliminated food storage. The crop - a pouch in the esophagus where most birds store food - is nearly vestigial in hummingbirds. Food passes almost directly to the intestines because storage would add unnecessary weight and delay energy availability.

Did You Know? Hummingbirds can switch from burning stored fat to burning freshly consumed sugar within minutes. They're essentially running on \"just-in-time\" fuel delivery - like a race car that refuels while still on the track!

Intestinal Adaptations

The small intestine is where the magic happens:

Species Spotlight: Rufous Hummingbird
The Rufous Hummingbird (Selasphorus rufus) migrates up to 3,900 miles from Mexico to Alaska - the longest migration relative to body size of any bird. To fuel this journey, its digestive system can process nectar so efficiently that it gains 10% of body weight per day during migration stopovers, building crucial fat reserves.

Processing Speed

The result of these adaptations is astounding: sugars from nectar can be absorbed and available for use within 15-20 minutes of consumption. This is roughly 10 times faster than human sugar digestion. The speed is essential because a hovering hummingbird burns through its available energy in just 15-20 minutes - it quite literally cannot afford slow digestion.

Water Management

Nectar is typically 20-25% sugar and 75-80% water. Consuming half their body weight in nectar means hummingbirds also take in enormous amounts of water. Their kidneys work overtime, producing very dilute urine to expel excess water while retaining essential electrolytes.

Human Comparison: If human kidneys worked as hard as hummingbird kidneys (proportionally), we'd need to process about 150 liters of water per day - roughly 40 gallons, or the equivalent of taking 300 trips to the bathroom!
", + "exercise_questions": [ + {"type": "single_select", "id": "hb-digest-q1", "question": "A hummingbird feeds at 8:00 AM. By what time should the energy from that nectar be available for hovering flight?", "correct_answer": "8:15-8:20 AM (15-20 minutes)", "all_answers": ["8:01-8:02 AM (1-2 minutes)", "8:15-8:20 AM (15-20 minutes)", "9:00-10:00 AM (1-2 hours)", "2:00 PM (6+ hours)"]}, + {"type": "single_select", "id": "hb-digest-q2", "question": "A biochemist is developing an artificial nectar for hummingbird feeders. The nectar contains sucrose (table sugar). Which enzyme must be present in high concentrations in hummingbird intestines to process this?", "correct_answer": "Sucrase (breaks down sucrose)", "all_answers": ["Sucrase (breaks down sucrose)", "Amylase (breaks down starch)", "Lipase (breaks down fats)", "Pepsin (breaks down proteins)"]}, + {"type": "single_select", "id": "hb-digest-q3", "question": "An evolutionary biologist compares bird digestive systems. Why would a large crop (food storage organ) actually be HARMFUL for a hummingbird's lifestyle?", "correct_answer": "Added weight slows flight and stored food delays energy availability", "all_answers": ["Added weight slows flight and stored food delays energy availability", "Large crops attract predators", "Crops interfere with wing movement", "Hummingbirds are allergic to crops"]} + ] + }, + { + "source_id": "hb-energy-metabolism", + "title": "Energy & Metabolism", + "learning_objective_indices": [4, 5], + "content": "

Hummingbirds live on the edge of energy balance. Their metabolic rate during flight is the highest of any vertebrate, yet they have virtually no ability to store energy as fat. This precarious situation has led to fascinating adaptations.

Active vs Torpor: Metabolic StatesACTIVE FLIGHTHeart: 1,200 bpmTemp: 40°C (104°F)Metabolism: 100%Burns through reservesin 15-20 minutesTORPORHeart: 50 bpmTemp: 18°C (64°F)Metabolism: 5%Survives 8-12 hourswithout feeding95% Reduction!Torpor reduces energy needs by up to 95%Like turning off 19 of every 20 light bulbsWake-up Cost20-60 minutes of shivering to rewarmVulnerable to predators during this time

Metabolic Extremes

The numbers are staggering:

Did You Know? A hummingbird's heart beats so fast that individual beats blend together into a continuous hum - that's where the name \"hummingbird\" actually comes from (along with their wing sound). A doctor using a stethoscope would hear a buzz, not distinct heartbeats!

The Energy Crisis of Night

With essentially no fat reserves, how do hummingbirds survive overnight without feeding? The answer is torpor - a state of controlled hypothermia that represents one of the most dramatic physiological adaptations in the animal kingdom.

Species Spotlight: Andean Hillstar
The Andean Hillstar (Oreotrochilus estella) lives at elevations up to 17,100 feet where nighttime temperatures drop below freezing. It enters torpor almost every night, allowing its body temperature to drop to just 6.8°C (44°F) - the lowest recorded for any bird. This extreme torpor lets it survive in conditions that would kill most other hummingbirds.

Torpor: Controlled Shutdown

As night approaches, hummingbirds can enter torpor:

Awakening from Torpor

Emerging from torpor takes 20-60 minutes of intense shivering as the bird gradually rewarms. During this vulnerable period, hummingbirds cannot fly or escape predators. The energy savings of torpor must be balanced against these risks, and not all hummingbirds enter torpor every night - it depends on energy reserves and environmental conditions.

Human Comparison: If a human entered a torpor-like state proportional to a hummingbird's, our heart rate would drop from 70 bpm to about 3 bpm, our body temperature would fall to 15°C (59°F), and we'd survive on just 100 calories per day instead of 2,000. Medical science is actually studying torpor to develop suspended animation for long space flights!
", + "exercise_questions": [ + {"type": "single_select", "id": "hb-energy-q1", "question": "A birdwatcher finds a hummingbird on its perch at dawn that appears dead - cold, unresponsive, barely breathing. What is the most likely explanation?", "correct_answer": "The bird is in torpor and will revive as it warms up", "all_answers": ["The bird is in torpor and will revive as it warms up", "The bird is performing a mating display", "The bird is sick and needs veterinary care immediately", "The bird is simply sleeping normally"]}, + {"type": "single_select", "id": "hb-energy-q2", "question": "A researcher wants to study how much energy torpor saves. If a hummingbird normally uses 100 units of energy per hour while resting, approximately how many units would it use during torpor?", "correct_answer": "About 5 units (95% reduction)", "all_answers": ["About 75 units (25% reduction)", "About 50 units (50% reduction)", "About 25 units (75% reduction)", "About 5 units (95% reduction)"]}, + {"type": "single_select", "id": "hb-energy-q3", "question": "A scientist is monitoring a hummingbird with a tiny heart rate sensor. During hovering flight, approximately how many heartbeats should register per minute?", "correct_answer": "Around 1,200 beats per minute", "all_answers": ["Around 200 beats per minute", "Around 500 beats per minute", "Around 1,200 beats per minute", "Over 2,000 beats per minute"]} + ] + } + ], + "prepost_questions": [ + {"id": "hb-metab-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "What is the primary food source for hummingbirds?", "correct_answer": "Nectar from flowers", "all_answers": ["Nectar from flowers", "Seeds", "Fish", "Leaves"]}, + {"id": "hb-metab-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "What provides protein in the hummingbird diet?", "correct_answer": "Small insects and spiders", "all_answers": ["Small insects and spiders", "Nectar", "Pollen", "Fruit"]}, + {"id": "hb-metab-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "How much of their body weight in nectar do they consume daily?", "correct_answer": "About half", "all_answers": ["10%", "25%", "About half", "Twice their weight"]}, + {"id": "hb-metab-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What physical adaptation helps extract nectar?", "correct_answer": "Specialized bill and extensible tongue", "all_answers": ["Specialized bill and extensible tongue", "Large crop", "Strong beak for cracking", "Webbed feet"]}, + {"id": "hb-metab-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "Why do hummingbirds have minimal crop storage?", "correct_answer": "To minimize weight and speed digestion", "all_answers": ["To minimize weight and speed digestion", "To store more food", "To improve flight acoustics", "To attract mates"]}, + {"id": "hb-metab-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "Where does most sugar absorption occur?", "correct_answer": "Small intestine", "all_answers": ["Crop", "Stomach", "Small intestine", "Liver"]}, + {"id": "hb-metab-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "What enzyme is key to sugar processing?", "correct_answer": "Sucrase", "all_answers": ["Sucrase", "Lactase", "Maltase", "Pepsin"]}, + {"id": "hb-metab-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "How quickly is sugar absorbed after feeding?", "correct_answer": "15-20 minutes", "all_answers": ["1-2 minutes", "15-20 minutes", "1-2 hours", "6-8 hours"]}, + {"id": "hb-metab-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "Why do hummingbirds enter torpor?", "correct_answer": "To survive overnight without feeding", "all_answers": ["To survive overnight without feeding", "To fly faster", "To attract mates", "To digest food"]}, + {"id": "hb-metab-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "What happens to body temperature during torpor?", "correct_answer": "Drops dramatically (can reach 18°C)", "all_answers": ["Drops dramatically (can reach 18°C)", "Increases", "Stays the same", "Fluctuates rapidly"]}, + {"id": "hb-metab-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "What is the heart rate during active flight?", "correct_answer": "Up to 1,200 beats per minute", "all_answers": ["100 beats per minute", "400 beats per minute", "Up to 1,200 beats per minute", "2,000+ beats per minute"]}, + {"id": "hb-metab-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "How long does it take to emerge from torpor?", "correct_answer": "20-60 minutes of shivering", "all_answers": ["Instantly", "5-10 minutes", "20-60 minutes of shivering", "Several hours"]} + ] + }, + { + "source_id": "hb-flight-mechanics", + "title": "Flight Mechanics", + "description": "Discover how hummingbirds achieve their remarkable flight abilities", + "learning_objectives": [ + "Identify the unique aspects of hummingbird wing anatomy", + "Explain how wing structure enables hovering", + "Describe the mechanics of hovering flight", + "Explain how hummingbirds generate lift on both strokes", + "Describe the aerial maneuvers hummingbirds can perform", + "Explain the physics of backward and sideways flight" + ], + "lessons": [ + { + "source_id": "hb-wing-anatomy", + "title": "Wing Anatomy", + "learning_objective_indices": [0, 1], + "content": "

Hummingbird wings are unlike those of any other bird. While most birds have wings optimized for soaring, gliding, or powerful flapping flight, hummingbird wings are designed for something far more demanding: sustained hovering in still air.

Wing Structure ComparisonTypical BirdArm 50%Hand 50%Hummingbird20%Hand 80%Limited rotation~90°Ball-and-socket180° rotationFlexible for soaringRigid like helicopter blade

Structural Differences

Several key features distinguish hummingbird wings:

Did You Know? Hummingbird wings have evolved to be more similar to insect wings than to other bird wings. In fact, their flight mechanics were so poorly understood that scientists had to use high-speed cameras filming at 1,000+ frames per second to finally figure out how hovering works!

The Ball-and-Socket Shoulder

The most crucial adaptation is the shoulder joint. Unlike other birds with more limited shoulder mobility, hummingbirds have a true ball-and-socket joint that allows:

Species Spotlight: Violet Sabrewing
The Violet Sabrewing (Campylopterus hemileucurus) of Central America is one of the largest hummingbirds outside South America, with a wingspan of 7 inches. Despite its larger size, its wing structure maintains the same 80% primary ratio, allowing it to hover just as effectively as its smaller relatives - it just beats its wings more slowly (about 15 times per second vs. 80+ for tiny species).

Feather Arrangement

The flight feathers are arranged to create an efficient airfoil that works both right-side-up and upside-down. This symmetrical design is essential for generating lift on both strokes of the wing beat cycle.

Human Comparison: If our arms worked like hummingbird wings, we could rotate them 180 degrees at the shoulder - turning our palms from facing the ceiling to facing the floor by rotating at the shoulder alone, not the wrist. Try it - you'll find humans can only rotate about 90 degrees before having to use the forearm!
", + "exercise_questions": [ + {"type": "single_select", "id": "hb-wing-q1", "question": "A drone engineer wants to build a micro-UAV that can hover like a hummingbird. Based on hummingbird wing design, what characteristics should the drone's 'wings' have?", "correct_answer": "Short and rigid, like helicopter blades", "all_answers": ["Short and rigid, like helicopter blades", "Long and flexible for soaring", "Identical to standard airplane wings", "No wings needed - use propellers only"]}, + {"type": "single_select", "id": "hb-wing-q2", "question": "A robotics team is designing an articulated arm for a hovering robot. What type of joint would best mimic hummingbird shoulder function?", "correct_answer": "Ball-and-socket joint allowing 180° rotation", "all_answers": ["Hinge joint like a door", "Ball-and-socket joint allowing 180° rotation", "Pivot joint like turning a key", "Fixed joint for stability"]}, + {"type": "single_select", "id": "hb-wing-q3", "question": "An ornithologist measures a hummingbird wing that is 5cm long. Based on typical hummingbird proportions, approximately how long is the 'hand' section (primaries)?", "correct_answer": "About 4cm (80% of wing length)", "all_answers": ["About 1.5cm (30%)", "About 2.5cm (50%)", "About 4cm (80% of wing length)", "About 4.75cm (95%)"]} + ] + }, + { + "source_id": "hb-hovering-flight", + "title": "Hovering Flight", + "learning_objective_indices": [2, 3], + "content": "

Hummingbirds are the only birds capable of true sustained hovering in still air. While other birds like kingfishers and kestrels can hover briefly, they require wind assistance. Hummingbirds achieve this feat through a completely unique wing motion.

Figure-8 Wing PatternRotate 180°Rotate 180°Forward strokeBackward strokeForward Stroke50%of total liftBackward Stroke50%of total liftOther birds: 70-80% downstroke only

The Figure-8 Pattern

During hovering, hummingbird wings trace a figure-8 pattern (or more precisely, a horizontal oval) rather than flapping up and down:

Did You Know? This wing pattern wasn't fully understood until 2005 when researchers at UC Berkeley used high-speed cameras and robot models to finally crack the code. Before then, scientists couldn't explain how hummingbirds generated enough lift to hover - the physics just didn't seem to work with traditional bird flight models!

Lift on Both Strokes

The genius of this system is that it generates lift during both the forward and backward strokes:

Species Spotlight: Amethyst Woodstar
The tiny Amethyst Woodstar (Calliphlox amethystina) of South America weighs only 2.5 grams and has one of the fastest wing beats - up to 80 times per second during hovering! At that speed, each complete figure-8 cycle takes just 0.0125 seconds. The wing tip travels at about 30 mph despite the tiny wingspan.

Speed and Control

Wing beat frequency varies with species size and activity:

Each wing can be controlled independently, allowing for instantaneous adjustments in position and attitude.

Human Comparison: Imagine waving your arms back and forth 80 times per second while also rotating your hands 180 degrees at each end. That's 160 hand rotations per second! Even Olympic swimmers, who have among the fastest arm movements of any athletes, only complete about 1.5 arm cycles per second.
", + "exercise_questions": [ + {"type": "single_select", "id": "hb-hover-q1", "question": "A slow-motion video of a hovering hummingbird shows wing tip positions. When connected, what shape would these positions form?", "correct_answer": "A figure-8 or horizontal oval pattern", "all_answers": ["A simple circle", "A figure-8 or horizontal oval pattern", "A triangle", "A straight vertical line (up and down)"]}, + {"type": "multiple_select", "id": "hb-hover-q2", "question": "A physicist analyzing hummingbird flight measures lift force during each wing stroke phase. During which phases should significant lift be detected?", "correct_answers": ["During the forward stroke", "During the backward stroke"], "all_answers": ["During the forward stroke", "During the backward stroke", "Neither phase produces lift", "Only when changing direction"]}, + {"type": "single_select", "id": "hb-hover-q3", "question": "An animator creating a realistic hummingbird hovering animation asks: what must happen to the wing at the end of each forward and backward stroke?", "correct_answer": "The wing must rotate 180 degrees to keep the leading edge forward", "all_answers": ["The wing should stop completely before reversing", "The wing must rotate 180 degrees to keep the leading edge forward", "The wing should fold against the body momentarily", "Nothing special - it just reverses direction"]} + ] + }, + { + "source_id": "hb-aerial-maneuvers", + "title": "Aerial Maneuvers", + "learning_objective_indices": [4, 5], + "content": "

Beyond hovering, hummingbirds can perform aerial maneuvers that no other bird can match. Their flight abilities have been described as 'helicopter-like,' but they actually exceed helicopter capabilities in several ways.

Multi-Directional Flight CapabilityHoverUpDownForward35 mphBackward!(unique)DiagonalAny angle+ Brief upside-down flight for escapes!

Directional Flight

Hummingbirds can fly in any direction:

Did You Know? Hummingbirds can perform an instant 180-degree turn in less than 0.01 seconds - faster than the blink of a human eye! This escape reflex is so fast that predators like praying mantises and dragonflies, which also have extremely fast reflexes, still usually miss when striking at hummingbirds.

How Backward Flight Works

Backward flight is achieved by tilting the wing stroke plane backward rather than forward. The wing still traces its figure-8 pattern, but the orientation directs thrust backward (pushing the bird in reverse). The tail helps with stability during this unusual maneuver.

Species Spotlight: Costa's Hummingbird
Male Costa's Hummingbirds (Calypte costae) of the Sonoran Desert perform one of the most extreme courtship dives. They climb to over 100 feet, then plummet at 60+ mph, pulling up with forces of 9g - more than fighter pilots experience! The dive produces a high-pitched whistle from their tail feathers, tuned to frequencies that female Costa's find most attractive.

Courtship Dives

Male hummingbirds perform spectacular dive displays:

Escape Maneuvers

When threatened, hummingbirds can execute instant direction changes that would destroy a mechanical aircraft. They can accelerate from zero to 30 mph in less than a meter, or reverse direction almost instantaneously. Their reaction time is among the fastest in the animal kingdom.

Human Comparison: Fighter pilots wear special G-suits and train extensively to handle 9g forces for a few seconds - and they still sometimes black out. At 9g, a 150-pound person feels like they weigh 1,350 pounds. A hummingbird experiences these forces during every courtship dive, multiple times per day, with no ill effects!
", + "exercise_questions": [ + {"type": "multiple_select", "id": "hb-maneuver-q1", "question": "A drone engineer wants to match hummingbird maneuverability. Which flight directions must the drone achieve to equal hummingbird capability?", "correct_answers": ["Forward flight", "Backward flight", "Sideways/lateral flight"], "all_answers": ["Forward flight", "Backward flight", "Sideways/lateral flight", "Forward only (like most drones)"]}, + {"type": "single_select", "id": "hb-maneuver-q2", "question": "A sports scientist studying extreme accelerations compares a hummingbird courtship dive to human activities. At what speed does the hummingbird dive?", "correct_answer": "60+ mph, faster than most highway speed limits", "all_answers": ["20 mph, like a slow bicycle", "40 mph, like urban driving", "60+ mph, faster than most highway speed limits", "100 mph, like a race car"]}, + {"type": "single_select", "id": "hb-maneuver-q3", "question": "A videographer films a hummingbird flying backward away from a flower. What is the bird doing to achieve this unique maneuver?", "correct_answer": "Tilting the wing stroke plane backward to redirect thrust", "all_answers": ["Flying in tight circles", "Using only the tail as a propeller", "Tilting the wing stroke plane backward to redirect thrust", "This was video editing - birds can't fly backward"]} + ] + } + ], + "prepost_questions": [ + {"id": "hb-flight-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "Which feathers make up most of the wing length?", "correct_answer": "Primaries", "all_answers": ["Primaries", "Secondaries", "Coverts", "Down feathers"]}, + {"id": "hb-flight-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "Hummingbird wings function most similarly to what?", "correct_answer": "Insect wings or helicopter blades", "all_answers": ["Eagle wings", "Insect wings or helicopter blades", "Bat wings", "Fish fins"]}, + {"id": "hb-flight-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "How many degrees can hummingbird wings rotate?", "correct_answer": "180 degrees", "all_answers": ["45 degrees", "90 degrees", "180 degrees", "360 degrees"]}, + {"id": "hb-flight-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What enables the extreme wing rotation?", "correct_answer": "Ball-and-socket shoulder joint", "all_answers": ["Ball-and-socket shoulder joint", "Extra wing bones", "Longer feathers", "Flexible spine"]}, + {"id": "hb-flight-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "What pattern do wings trace during hovering?", "correct_answer": "Figure-8 (horizontal oval)", "all_answers": ["Circle", "Figure-8 (horizontal oval)", "Up and down", "Random"]}, + {"id": "hb-flight-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "Can any other bird truly hover in still air?", "correct_answer": "No, only hummingbirds", "all_answers": ["No, only hummingbirds", "Yes, eagles", "Yes, all birds", "Yes, kingfishers without wind"]}, + {"id": "hb-flight-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "On how many strokes do hummingbirds generate lift?", "correct_answer": "Both forward and backward strokes", "all_answers": ["Downstroke only", "Upstroke only", "Both forward and backward strokes", "Neither"]}, + {"id": "hb-flight-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "How does lift compare between strokes?", "correct_answer": "About 50% from each", "all_answers": ["Downstroke is much stronger", "Upstroke is much stronger", "About 50% from each", "No lift on either"]}, + {"id": "hb-flight-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "Can hummingbirds fly upside down?", "correct_answer": "Yes, briefly for escape maneuvers", "all_answers": ["No", "Yes, briefly for escape maneuvers", "Yes, for long periods", "Only during sleep"]}, + {"id": "hb-flight-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "What is the forward flight top speed?", "correct_answer": "30-35 mph", "all_answers": ["10 mph", "20 mph", "30-35 mph", "60+ mph"]}, + {"id": "hb-flight-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "How do hummingbirds achieve backward flight?", "correct_answer": "Tilting the wing stroke plane backward", "all_answers": ["Tilting the wing stroke plane backward", "Flying in circles", "Using tail as rudder only", "They cannot fly backward"]}, + {"id": "hb-flight-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "What G-forces do hummingbirds experience in courtship dives?", "correct_answer": "Over 9g", "all_answers": ["1-2g", "3-4g", "Over 9g", "No G-forces"]} + ] + } + ] + }, + { + "source_id": "intro-computing", + "title": "Introductory Computing", + "description": "Learn fundamental programming concepts", + "units": [ + { + "source_id": "cs-data-variables", + "title": "Data & Variables", + "description": "Understand how computers store and manipulate information", + "learning_objectives": [ + "Define what data means in computing", + "Identify different types of data", + "Explain the purpose of variables", + "Describe how to declare and assign variables", + "List common data operations", + "Apply arithmetic operations to numeric data" + ], + "lessons": [ + { + "source_id": "cs-what-is-data", + "title": "What is Data?", + "learning_objective_indices": [0, 1], + "content": "

At its core, computing is about processing data - transforming information from one form into another, analyzing it, storing it, and presenting it in useful ways. Before we can write programs, we need to understand what data actually is.

Common Data TypesInteger42Whole numbersFloat3.14DecimalsString\"Hello\"Text in quotesBooleanTrueTrue/FalseAll data is stored as binary (1s and 0s)01001000 01101001Above: \"Hi\" in binary (8 bits per character)Data Types = high-level abstractions over binary

Data: Information for Computers

Data is any information that a computer can store and process. This incredibly broad definition encompasses:

Did You Know? A single 4K video frame contains about 25 million pixels, each with color data. At 60 frames per second, that's 1.5 billion pieces of data every second! Modern computers process this in real-time while you watch YouTube.

Binary: The Language of Computers

While humans think in decimal (base 10) and communicate with complex languages, computers fundamentally work in binary - just 1s and 0s. Every piece of data, no matter how complex, is ultimately represented as sequences of these binary digits (bits).

However, programming would be impossibly tedious if we had to work directly in binary. Instead, programming languages provide higher-level abstractions called data types.

Real-World Application: Video Games
When you play a video game, the computer tracks thousands of variables: your position (floats like 123.45, 67.89), your health (integer like 85), your character name (string like \"DragonSlayer99\"), and whether you're jumping (boolean True/False). Every frame, these get updated 60+ times per second!

Common Data Types

Programming languages categorize data into types, each with its own properties and operations:

Understanding data types is essential because operations that work on one type might not work on another - you can add numbers, but what does it mean to divide text?

Try It Yourself:
age = 25              # integer\nprice = 19.99         # float\nname = \"Alice\"        # string\nis_student = True     # boolean\nprint(type(age))      # Shows: <class 'int'>
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-data-q1", "question": "A computer scientist explains that your MP3 files, photos, and documents are all stored the same way at the hardware level. What format is that?", "correct_answer": "Binary - sequences of 1s and 0s", "all_answers": ["Binary - sequences of 1s and 0s", "Alphabetic letters", "RGB colors", "Analog sound waves"]}, + {"type": "multiple_select", "id": "cs-data-q2", "question": "You're building a student database. Which programming data types would you need to store: name, age, GPA, and graduation status?", "correct_answers": ["String for the name", "Integer for the age", "Boolean for graduation status"], "all_answers": ["String for the name", "Integer for the age", "Boolean for graduation status", "Emotion for happiness level"]}, + {"type": "single_select", "id": "cs-data-q3", "question": "A login system needs to track whether a user is currently logged in. What data type best represents this on/off state?", "correct_answer": "Boolean (True/False)", "all_answers": ["Integer", "String", "Boolean (True/False)", "Float"]} + ] + }, + { + "source_id": "cs-variables-types", + "title": "Variables & Types", + "learning_objective_indices": [2, 3], + "content": "

If data is the information computers work with, variables are the containers that hold that data. Understanding variables is fundamental to programming - they're one of the first concepts every programmer learns.

Variables: Labeled Boxes for Dataplayer_score42user_name\"Alice\"is_activeTruename = labelvalue = contentsplayer_score = 42\"Put 42 in the box labeled player_score\"

What is a Variable?

A variable is a named storage location in the computer's memory. Think of it like a labeled box:

Did You Know? The term \"variable\" comes from mathematics, but programming variables work differently. In math, x = 5 is a statement of equality. In programming, x = 5 is a command: \"store 5 in the box labeled x.\"

Declaring and Assigning Variables

In most programming languages, you create and use variables in two steps:

  1. Declaration: Telling the computer you want a variable with a specific name
  2. Assignment: Putting a value into that variable

In Python, these often happen together:

age = 25           # Creates 'age' and assigns 25\nname = \"Alice\"     # Creates 'name' and assigns \"Alice\"\nis_student = True  # Creates 'is_student' and assigns True

The equals sign (=) is the assignment operator - it puts the value on the right into the variable on the left.

Real-World Application: Shopping Cart
Online stores use variables constantly:
cart_total = 0.00\nitem_count = 0\nuser_logged_in = False\ndiscount_code = \"SAVE20\"
As you shop, these variables update with every action you take.

Naming Variables

Good variable names make code readable:

Variables Can Change

Unlike mathematical variables, programming variables can be reassigned:

count = 0      # count is 0\ncount = 1      # count is now 1\ncount = 2      # count is now 2

This ability to change is what makes programs dynamic and useful.

Try It Yourself:
# Watch a variable change\nlives = 3\nprint(\"Starting lives:\", lives)  # 3\nlives = lives - 1\nprint(\"After hit:\", lives)       # 2
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-var-q1", "question": "A game developer needs to track a player's current health points, which changes as the player takes damage or heals. What programming concept should they use?", "correct_answer": "A variable (named container that can change)", "all_answers": ["A variable (named container that can change)", "A new computer each time", "A mathematical equation", "A different programming language"]}, + {"type": "single_select", "id": "cs-var-q2", "question": "Looking at this code: `high_score = 5000`, which part identifies WHERE the value is stored?", "correct_answer": "high_score (the variable name)", "all_answers": ["5000 (the number)", "high_score (the variable name)", "integer (the data type)", "= (the equals sign)"]}, + {"type": "single_select", "id": "cs-var-q3", "question": "A programmer writes `temperature = 72`. Later they write `temperature = 85`. What happened?", "correct_answer": "The old value (72) was replaced with the new value (85)", "all_answers": ["The code tested if temperature equals 72", "The old value (72) was replaced with the new value (85)", "An error occurred - variables can't change", "Two separate variables were created"]} + ] + }, + { + "source_id": "cs-operations-data", + "title": "Operations on Data", + "learning_objective_indices": [4, 5], + "content": "

Data becomes useful when we operate on it - when we calculate, compare, combine, and transform. Programming languages provide operators and functions for these operations.

Python Arithmetic Operators+5 + 3 = 8-10 - 4 = 6*4 * 7 = 28/15 / 3 = 5.0//17 // 5 = 3 (floor)%17 % 5 = 2 (mod)**2 ** 3 = 8 (power)Order of Operations (PEMDAS)10 + 5 * 2 = 20 (not 30!) — multiply firstString Concatenation\"Hello\" + \" \" + \"World\" = \"Hello World\"

Arithmetic Operations

The basic mathematical operations work as you'd expect:

Did You Know? The modulo operator (%) is secretly one of the most useful operators! It's used to check if numbers are even/odd, to make things wrap around (like clock arithmetic), and to create patterns in games. 17 % 2 equals 1, so 17 is odd!

Order of Operations

Just like in mathematics, operations follow a specific order (PEMDAS):

  1. Parentheses
  2. Exponents
  3. Multiplication and Division (left to right)
  4. Addition and Subtraction (left to right)

So 10 + 5 * 2 equals 20 (not 30), because multiplication happens first.

Real-World Application: E-Commerce
subtotal = price * quantity\ntax = subtotal * 0.08\nshipping = 5.99 if subtotal < 50 else 0\ntotal = subtotal + tax + shipping
Every online checkout uses these exact operations!

String Operations

Strings have their own operations:

Comparison Operations

These return Boolean (True/False) values:

Note: == tests equality, = assigns values - confusing them is a common beginner mistake!

Try It Yourself:
# Calculate a tip\nmeal = 45.00\ntip_percent = 0.20\ntip = meal * tip_percent\nprint(\"Tip:\", tip)           # 9.0\nprint(\"Total:\", meal + tip)  # 54.0
", + "exercise_questions": [ + {"type": "multiple_select", "id": "cs-ops-q1", "question": "You're writing a calculator app. Which Python operators would you need to implement basic math functions?", "correct_answers": ["+ for addition", "- for subtraction", "* for multiplication", "/ for division"], "all_answers": ["+ for addition", "- for subtraction", "* for multiplication", "/ for division"]}, + {"type": "single_select", "id": "cs-ops-q2", "question": "A social media app creates usernames by combining first and last name: first_name + \"_\" + last_name. What is this string operation called?", "correct_answer": "Concatenation (joining strings)", "all_answers": ["Concatenation (joining strings)", "Deletion", "Comparison", "Sorting"]}, + {"type": "single_select", "id": "cs-ops-q3", "question": "A shopping cart calculates: base_price + shipping * item_count where base_price=10, shipping=5, item_count=2. What's the total due to order of operations?", "correct_answer": "20 (shipping * item_count happens first)", "all_answers": ["15", "20 (shipping * item_count happens first)", "30 (everything happens left to right)", "25"]} + ] + } + ], + "prepost_questions": [ + {"id": "cs-data-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "What is data in computing?", "correct_answer": "Information that computers can process", "all_answers": ["Information that computers can process", "Only numbers", "Only text", "Computer hardware"]}, + {"id": "cs-data-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "What does a computer process?", "correct_answer": "Data", "all_answers": ["Data", "Electricity only", "Nothing", "Physical objects"]}, + {"id": "cs-data-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "Which is a numeric data type?", "correct_answer": "Integer", "all_answers": ["Integer", "Boolean", "String", "List"]}, + {"id": "cs-data-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What data type represents True/False?", "correct_answer": "Boolean", "all_answers": ["Integer", "String", "Boolean", "Float"]}, + {"id": "cs-data-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "Why do we use variables?", "correct_answer": "To store and reuse values", "all_answers": ["To store and reuse values", "To make code slower", "To confuse programmers", "Variables are not useful"]}, + {"id": "cs-data-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "What can a variable hold?", "correct_answer": "A value of some data type", "all_answers": ["A value of some data type", "Another computer", "Hardware components", "Nothing useful"]}, + {"id": "cs-data-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "What does 'x = 5' do?", "correct_answer": "Assigns 5 to variable x", "all_answers": ["Assigns 5 to variable x", "Tests if x equals 5", "Deletes x", "Prints 5"]}, + {"id": "cs-data-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "In 'name = \"Alice\"', what is \"Alice\"?", "correct_answer": "The value being assigned", "all_answers": ["The variable name", "The value being assigned", "An operator", "A function"]}, + {"id": "cs-data-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "What symbol represents multiplication?", "correct_answer": "*", "all_answers": ["+", "-", "*", "/"]}, + {"id": "cs-data-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "What does the % operator return?", "correct_answer": "The remainder of division", "all_answers": ["A percentage", "The remainder of division", "A ratio", "An error"]}, + {"id": "cs-data-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "What is 10 + 5 * 2?", "correct_answer": "20", "all_answers": ["15", "20", "30", "25"]}, + {"id": "cs-data-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "What is (10 + 5) * 2?", "correct_answer": "30", "all_answers": ["20", "25", "30", "35"]} + ] + }, + { + "source_id": "cs-control-flow", + "title": "Control Flow", + "description": "Learn how to control program execution", + "learning_objectives": [ + "Explain what conditional statements do", + "Write simple if/else statements", + "Describe the purpose of loops", + "Differentiate between for and while loops", + "Combine conditionals and loops", + "Trace execution flow through nested structures" + ], + "lessons": [ + { + "source_id": "cs-conditionals", + "title": "Conditionals", + "learning_objective_indices": [0, 1], + "content": "

Real programs need to make decisions. Should we display an error message? Is the user old enough? Does the password match? Conditional statements let programs choose different paths based on conditions.

Decision Making with if/elif/elseCheckTrueFalseif block runselse block runsif score >= 90: grade = \"A\"elif score >= 80: grade = \"B\"else: grade = \"F\"

The if Statement

The fundamental conditional is the if statement. Its basic form:

if condition:\n    # code to run if condition is True\n    do_something()

The code inside the if block only runs when the condition evaluates to True. If it's False, that code is skipped entirely.

Did You Know? Every time you unlock your phone, dozens of if statements run: Is the fingerprint valid? Is the PIN correct? Is Face ID recognized? Should notifications be shown? Conditionals are the backbone of all interactive software!

Adding else

Often we want to do one thing if a condition is true and something different if it's false:

if temperature > 30:\n    print(\"It's hot!\")\nelse:\n    print(\"It's not too hot.\")

The else block runs when the if condition is False.

Real-World Application: Login System
if password == stored_password:\n    print(\"Welcome back!\")\n    show_dashboard()\nelse:\n    print(\"Invalid password\")\n    attempts += 1
Every website login uses this exact pattern!

Multiple Conditions with elif

For more than two possibilities, use elif (else if):

if score >= 90:\n    grade = \"A\"\nelif score >= 80:\n    grade = \"B\"\nelif score >= 70:\n    grade = \"C\"\nelse:\n    grade = \"F\"

Python checks each condition in order and runs the first matching block.

Boolean Expressions

Conditions are Boolean expressions - they evaluate to True or False:

Example: if age >= 18 and has_license:

Try It Yourself:
# A simple recommendation engine\nmood = \"happy\"\nweather = \"sunny\"\n\nif mood == \"happy\" and weather == \"sunny\":\n    print(\"Perfect day for a picnic!\")\nelif mood == \"tired\":\n    print(\"Maybe get some rest?\")\nelse:\n    print(\"How about a movie?\")
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-cond-q1", "question": "A login system should show 'Welcome!' only when the password is correct. What programming concept should the developer use?", "correct_answer": "An if statement that checks if password matches", "all_answers": ["An if statement that checks if password matches", "A loop that repeats the welcome message", "A variable called 'welcome'", "A print statement at the start"]}, + {"type": "single_select", "id": "cs-cond-q2", "question": "A weather app shows 'Bring an umbrella!' if it's raining, otherwise shows 'Enjoy the sun!'. When does the 'Enjoy the sun!' message display?", "correct_answer": "When the 'if raining' condition is False", "all_answers": ["When the 'if raining' condition is True", "When the 'if raining' condition is False", "Always, regardless of weather", "Never - it's broken code"]}, + {"type": "single_select", "id": "cs-cond-q3", "question": "A grading system needs to assign A, B, C, D, or F based on score. A developer uses 'if' for A, then multiple '____' for B, C, D, and 'else' for F. What goes in the blank?", "correct_answer": "elif (else if)", "all_answers": ["elif (else if)", "elseloop", "element", "errorif"]} + ] + }, + { + "source_id": "cs-loops", + "title": "Loops", + "learning_objective_indices": [2, 3], + "content": "

Loops are one of programming's most powerful tools. They let us repeat actions without writing the same code over and over. Need to process 1,000 files? A loop handles it as easily as processing one.

For Loop vs While Loopfor loopfor i in range(5):Known iterations\"Do this exactly 5 times\"\"Process each item\"while loopwhile count < 5:Unknown iterations\"Keep going until done\"\"Wait for user input\"Loop FlowStartCheck?yesDo itno → exitDone

The for Loop

Use a for loop when you know how many times to repeat, or when iterating over a collection:

# Repeat 5 times\nfor i in range(5):\n    print(i)  # Prints 0, 1, 2, 3, 4\n\n# Process each item in a list\nfor name in [\"Alice\", \"Bob\", \"Charlie\"]:\n    print(\"Hello, \" + name)

The loop variable (i, name) automatically takes each value in sequence.

Did You Know? When you scroll through Instagram, a for loop is displaying each post. When Spotify plays your playlist, a for loop is going through each song. Loops are everywhere!

The while Loop

Use a while loop when you don't know exactly how many iterations you need - when you want to continue until some condition changes:

count = 0\nwhile count < 5:\n    print(count)\n    count = count + 1  # Don't forget this!

Warning: If the condition never becomes False, you get an infinite loop!

Real-World Application: Game Loop
game_running = True\nwhile game_running:\n    get_player_input()\n    update_game_state()\n    draw_screen()\n    if player_quit:\n        game_running = False
Every video game runs inside a while loop!

When to Use Which

Loop Control

Two special statements control loop execution:

for i in range(10):\n    if i == 5:\n        break  # Stops at 5\n    print(i)  # Prints 0-4
Try It Yourself:
# Count down from 5\nfor i in range(5, 0, -1):\n    print(i)\nprint(\"Blastoff!\")\n# Output: 5, 4, 3, 2, 1, Blastoff!
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-loop-q1", "question": "A music player needs to play each song in a 50-song playlist. Instead of writing 50 separate 'play' commands, what should the developer use?", "correct_answer": "A loop that repeats the play command for each song", "all_answers": ["A loop that repeats the play command for each song", "50 different if statements", "A single variable", "A print statement"]}, + {"type": "single_select", "id": "cs-loop-q2", "question": "A chat application keeps checking for new messages until the user logs out. What causes this 'while checking' loop to stop?", "correct_answer": "When the logged_in condition becomes False", "all_answers": ["After checking exactly once", "When the logged_in condition becomes False", "It never stops (runs forever)", "After exactly 10 checks"]}, + {"type": "single_select", "id": "cs-loop-q3", "question": "A search function loops through items but should stop immediately when it finds what it's looking for. What command stops the loop early?", "correct_answer": "break - exits the loop immediately", "all_answers": ["break - exits the loop immediately", "continue - skips to next item", "restart - starts the loop over", "stop - this isn't a real command"]} + ] + }, + { + "source_id": "cs-combining-structures", + "title": "Combining Structures", + "learning_objective_indices": [4, 5], + "content": "

Real programs combine conditionals and loops to create sophisticated logic. A search algorithm loops through data while checking conditions. A game loop runs continuously, making decisions each frame.

Conditionals Inside Loops

One common pattern is checking conditions within a loop:

numbers = [1, -2, 3, -4, 5]\n\nfor num in numbers:\n    if num > 0:\n        print(f\"{num} is positive\")\n    else:\n        print(f\"{num} is negative or zero\")

Each iteration checks the condition independently.

Loops Inside Conditionals

The reverse is also useful - only looping when certain conditions are met:

if user_wants_report:\n    for item in data:\n        process(item)\n        print_report()

Nested Loops

Loops can contain other loops (be careful - this multiplies iterations!):

for row in range(3):\n    for col in range(3):\n        print(f\"({row},{col})\", end=\" \")\n    print()  # New line after each row

Output: (0,0) (0,1) (0,2), (1,0) (1,1) (1,2), (2,0) (2,1) (2,2)

Tracing Execution

When debugging complex nested structures, trace through the code step by step:

  1. Keep track of all variable values
  2. Note which branches are taken
  3. Follow loop iterations carefully
  4. Watch for off-by-one errors

This skill - mentally or on paper executing code - is invaluable for understanding and fixing programs.

", + "exercise_questions": [ + {"type": "single_select", "id": "cs-combine-q1", "question": "A shopping cart loops through all items, but only adds a discount if the item is on sale. What programming pattern is this?", "correct_answer": "An if statement inside a loop - very common!", "all_answers": ["An if statement inside a loop - very common!", "This would cause an error", "This only works in certain languages", "You can only check one item per loop"]}, + {"type": "single_select", "id": "cs-combine-q2", "question": "A calendar app has code that displays a monthly grid: an outer loop for rows (weeks) containing an inner loop for columns (days). What is this called?", "correct_answer": "Nested code - structures inside other structures", "all_answers": ["Nested code - structures inside other structures", "Broken code with errors", "Code that's too short", "Code that won't execute"]}, + {"type": "single_select", "id": "cs-combine-q3", "question": "A developer finds a bug where a loop runs one too many times. What technique helps them understand exactly what's happening?", "correct_answer": "Tracing - following code step by step with variable values", "all_answers": ["Tracing - following code step by step with variable values", "Making the code run faster", "Adding more comments", "Tracing isn't useful for debugging"]} + ] + } + ], + "prepost_questions": [ + {"id": "cs-flow-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "What do conditionals allow programs to do?", "correct_answer": "Make decisions based on conditions", "all_answers": ["Make decisions based on conditions", "Repeat code", "Store data", "Print output"]}, + {"id": "cs-flow-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "What keyword starts a conditional in Python?", "correct_answer": "if", "all_answers": ["for", "while", "if", "def"]}, + {"id": "cs-flow-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "What runs when 'if x > 5' is True?", "correct_answer": "The code in the if block", "all_answers": ["The code in the if block", "The else block", "Both blocks", "Neither block"]}, + {"id": "cs-flow-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What keyword provides an alternative path?", "correct_answer": "else", "all_answers": ["if", "else", "for", "while"]}, + {"id": "cs-flow-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "Why use loops?", "correct_answer": "To avoid writing repetitive code", "all_answers": ["To avoid writing repetitive code", "To make code slower", "To delete data", "Loops are not useful"]}, + {"id": "cs-flow-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "How many times can a loop execute?", "correct_answer": "As many times as needed", "all_answers": ["Exactly once", "Exactly twice", "As many times as needed", "Maximum 100"]}, + {"id": "cs-flow-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "Which loop is best for a known number of iterations?", "correct_answer": "for loop", "all_answers": ["for loop", "while loop", "if statement", "All are equal"]}, + {"id": "cs-flow-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "Which loop continues until a condition is false?", "correct_answer": "while loop", "all_answers": ["for loop", "while loop", "Both equally", "Neither"]}, + {"id": "cs-flow-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "Can conditionals be placed inside loops?", "correct_answer": "Yes", "all_answers": ["Yes", "No", "Only sometimes", "Only in certain languages"]}, + {"id": "cs-flow-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "Can loops be placed inside conditionals?", "correct_answer": "Yes", "all_answers": ["Yes", "No", "Only sometimes", "Only in certain languages"]}, + {"id": "cs-flow-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "What is tracing execution flow?", "correct_answer": "Following code logic step by step", "all_answers": ["Following code logic step by step", "Drawing diagrams", "Deleting code", "Writing documentation"]}, + {"id": "cs-flow-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "Why is understanding nested structures important?", "correct_answer": "To predict how the program will behave", "all_answers": ["To predict how the program will behave", "To make code look pretty", "It is not important", "To confuse others"]} + ] + }, + { + "source_id": "cs-functions-modularity", + "title": "Functions & Modularity", + "description": "Learn to organize code into reusable pieces", + "learning_objectives": [ + "Define what a function is in programming", + "Explain the benefits of using functions", + "Use parameters to pass data to functions", + "Use return values to get data from functions", + "Explain code modularity and reuse", + "Organize code into logical functions" + ], + "lessons": [ + { + "source_id": "cs-defining-functions", + "title": "Defining Functions", + "learning_objective_indices": [0, 1], + "content": "

As programs grow larger, organization becomes crucial. Functions are the primary tool for organizing code into manageable, reusable pieces.

Function: Define Once, Use Many TimesDEFINEdef greet(): print(\"Hello!\")(write once)CALLgreet()greet()(use many times!)Function Anatomydefgreet():↑keyword↑name↑params↑startIndented code below = function body

What is a Function?

A function is a named block of code that performs a specific task. Once defined, you can run that code by calling the function by name, as many times as needed.

def greet():\n    print(\"Hello!\")\n    print(\"Welcome to the program.\")\n\n# Call the function\ngreet()  # Prints both lines\ngreet()  # Prints them again
Did You Know? The average smartphone app contains thousands of functions! Instagram's app has functions for loading images, applying filters, posting comments, sending DMs, and much more. Without functions, the code would be millions of lines of unmaintainable chaos.

Why Use Functions?

Functions provide several critical benefits:

Real-World Application: Password Validation
def is_password_strong(password):\n    has_upper = any(c.isupper() for c in password)\n    has_number = any(c.isdigit() for c in password)\n    long_enough = len(password) >= 8\n    return has_upper and has_number and long_enough\n\n# Reuse everywhere passwords are needed!\nif is_password_strong(new_password):\n    save_password()

Function Anatomy

A function definition includes:

  1. def keyword: Tells Python you're defining a function
  2. Name: How you'll call the function later
  3. Parentheses: For parameters (covered next lesson)
  4. Colon: Indicates the start of the function body
  5. Indented body: The code that runs when you call the function

Calling Functions

To run a function's code, call it by name with parentheses:

greet()     # Correct - calls the function\ngreet       # Wrong - just references the function object
Try It Yourself:
# Define a simple function\ndef celebrate():\n    print(\"🎉 Congratulations!\")\n    print(\"You learned functions!\")\n\n# Call it three times\ncelebrate()\ncelebrate()\ncelebrate()
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-func-q1", "question": "Maya notices she's writing the same 5 lines of code to display a welcome message in 8 different places in her program. What programming concept should Maya use to avoid this repetition?", "correct_answer": "Create a function with those 5 lines and call it where needed", "all_answers": ["Create a function with those 5 lines and call it where needed", "Copy-paste is fine since it works", "Create 8 different variables", "Delete most of the welcome messages"]}, + {"type": "multiple_select", "id": "cs-func-q2", "question": "Alex created a function calculate_shipping() that is used in 15 places throughout their e-commerce application. Which scenarios demonstrate the benefits of this approach?", "correct_answers": ["When shipping rates change, Alex only edits one function", "Alex can test shipping calculations independently", "Team members can understand the code more easily"], "all_answers": ["When shipping rates change, Alex only edits one function", "Alex can test shipping calculations independently", "Team members can understand the code more easily", "The program runs twice as slow"]}, + {"type": "single_select", "id": "cs-func-q3", "question": "Jordan writes: 'function greet(): print(\"Hello\")' but Python gives an error. Looking at the function anatomy diagram, what did Jordan do wrong?", "correct_answer": "Used 'function' instead of 'def' keyword", "all_answers": ["Used 'function' instead of 'def' keyword", "Forgot the print statement", "The parentheses are wrong", "The message is incorrect"]} + ] + }, + { + "source_id": "cs-parameters-returns", + "title": "Parameters & Return Values", + "learning_objective_indices": [2, 3], + "content": "

Functions become truly powerful when they can receive input and produce output. Parameters let you pass data in; return values let you get data out.

Function: Input → Process → OutputINPUTParameters(a, b)PROCESSFunction bodyresult = a + bOUTPUTReturn valuereturn resulttotal = add(5, 3) → total is 85 and 3 go in, 8 comes out!

Parameters: Input for Functions

Parameters are variables that receive values when a function is called:

def greet(name):\n    print(\"Hello, \" + name + \"!\")\n\ngreet(\"Alice\")  # Prints: Hello, Alice!\ngreet(\"Bob\")    # Prints: Hello, Bob!

The parameter name takes whatever value is passed in.

Did You Know? Google's search function takes your query as a parameter and searches billions of web pages! The same function handles \"best pizza near me\" and \"quantum physics\" - the parameter just changes what it searches for.

Multiple Parameters

Functions can have multiple parameters, separated by commas:

Multiple Parametersdefcalculate_area(width, height):1st param2nd paramcalculate_area(10,5) # width=10, height=5
def add(a, b):\n    result = a + b\n    print(result)\n\nadd(3, 5)   # Prints: 8\nadd(10, 20) # Prints: 30
Real-World Application: Ride-Sharing App
def calculate_fare(distance_miles, surge_multiplier):\n    base_fare = 2.50\n    per_mile = 1.75\n    fare = base_fare + (distance_miles * per_mile)\n    return fare * surge_multiplier\n\n# Normal ride: 5 miles, no surge\nprint(calculate_fare(5, 1.0))   # $11.25\n\n# Busy hour: 5 miles, 2x surge\nprint(calculate_fare(5, 2.0))   # $22.50

Return Values: Output from Functions

The return statement sends a value back to the caller:

def add(a, b):\n    return a + b\n\nsum = add(3, 5)  # sum is now 8\nprint(sum)       # Prints: 8\n\n# Can use return value directly\nprint(add(10, 20))  # Prints: 30

Return Ends the Function

When return executes, the function stops immediately:

def check_positive(num):\n    if num > 0:\n        return \"Positive\"\n    return \"Not positive\"  # Only runs if num <= 0

Functions Without return

If a function doesn't explicitly return something, it returns None:

def say_hi():\n    print(\"Hi\")\n\nresult = say_hi()  # result is None
Try It Yourself:
# Function with parameter and return\ndef double(number):\n    return number * 2\n\n# Chain the results!\nresult = double(double(double(2)))\nprint(result)  # What do you get?

Answer: 2 → 4 → 8 → 16

", + "exercise_questions": [ + {"type": "single_select", "id": "cs-param-q1", "question": "Taylor writes a function to greet users: 'def greet(name): print(\"Hello, \" + name)'. When Taylor calls greet(\"Sam\"), what happens to the value \"Sam\"?", "correct_answer": "It's passed as the 'name' parameter and used inside the function", "all_answers": ["It's passed as the 'name' parameter and used inside the function", "It's printed before the function runs", "It replaces the function name", "It creates a new function called 'Sam'"]}, + {"type": "single_select", "id": "cs-param-q2", "question": "A ride-sharing app needs a function that calculates the fare based on distance and time. The app needs to display this fare on screen. Should the function use 'print' or 'return' for the calculated amount?", "correct_answer": "Return, so the app can use the value to display it however needed", "all_answers": ["Return, so the app can use the value to display it however needed", "Print, because that's how you output data", "Both print and return together", "Neither - just save it to a file"]}, + {"type": "single_select", "id": "cs-param-q3", "question": "Casey creates: 'def calculate_area(width, height): return width * height'. What will 'area = calculate_area(4, 5)' store in the variable 'area'?", "correct_answer": "20", "all_answers": ["20", "None", "The function itself", "An error because you can't store return values"]} + ] + }, + { + "source_id": "cs-code-organization", + "title": "Code Organization", + "learning_objective_indices": [4, 5], + "content": "

Functions are tools for creating modular, organized code. Good organization makes programs easier to understand, modify, and debug.

Single Responsibility PrincipleBAD: Does Too Muchprocess_order_and_send_email_and_update_db()Hard to test, reuse, debugGOOD: One Job Eachprocess_order()send_email()update_database()Breaking Down a Complex Taskvalidate()calculate()process()confirm()

The Single Responsibility Principle

Each function should do one thing and do it well:

If you struggle to name a function or find yourself using \"and\" in the name, it probably does too much.

Did You Know? The UNIX operating system was built on this philosophy: each program does one thing well. The 'ls' command just lists files. The 'cat' command just reads files. The 'grep' command just searches text. By combining these simple tools, you can do incredibly complex things!

Choosing Good Names

Function names should clearly describe what they do:

Naming: Be Specific!Vague Namesdo_stuff(), handle(), process()Descriptive Namesvalidate_email(), calculate_tax()
Real-World Application: Restaurant Kitchen

A well-organized kitchen has stations: one chef grills, one makes sauces, one plates. Each person has one responsibility. Code works the same way!

# Restaurant order processing\ndef process_order(order):\n    validate_order(order)      # Check order is valid\n    prepare_ingredients(order) # Get items ready\n    cook_order(order)          # Make the food\n    plate_order(order)         # Arrange presentation\n    notify_waiter(order)       # Ready to serve!

Breaking Down Complex Tasks

Large tasks should be decomposed into smaller functions:

# Instead of one huge function:\ndef process_order(order):\n    # 200 lines of code...\n\n# Break it into manageable pieces:\ndef process_order(order):\n    validate_order(order)\n    calculate_total(order)\n    apply_discounts(order)\n    process_payment(order)\n    send_confirmation(order)

Code Reuse

Once you have well-designed functions, reuse them:

def format_name(first, last):\n    return f\"{first} {last}\"\n\n# Reuse in multiple contexts\nprint(format_name(\"Alice\", \"Smith\"))\nemail_header = format_name(customer.first, customer.last)\nreport_line = format_name(employee.first, employee.last)

If you find yourself copying code, consider making it a function instead.

Try It Yourself:
# Refactor this into smaller functions!\ndef bad_greeting(name, age, city):\n    print(\"Hello \" + name)\n    if age >= 18:\n        print(\"You are an adult\")\n    else:\n        print(\"You are a minor\")\n    print(\"You live in \" + city)\n\n# Better: greet(), describe_age_group(), mention_location()
", + "exercise_questions": [ + {"type": "single_select", "id": "cs-org-q1", "question": "Jamie is reviewing code and finds a function named 'do_everything_for_checkout()' that handles cart validation, payment processing, inventory updates, and email confirmation (250 lines total). What advice should Jamie give?", "correct_answer": "Split it into separate functions, each handling one responsibility", "all_answers": ["Split it into separate functions, each handling one responsibility", "It's fine since it all relates to checkout", "Add more comments to explain the 250 lines", "Rename it to 'checkout_master_function()'"]}, + {"type": "multiple_select", "id": "cs-org-q2", "question": "A team lead asks Riley to improve the codebase's organization. Which of Riley's changes follow best practices?", "correct_answers": ["Renaming 'process()' to 'validate_user_email()'", "Breaking a 150-line function into 5 smaller functions"], "all_answers": ["Renaming 'process()' to 'validate_user_email()'", "Breaking a 150-line function into 5 smaller functions", "Combining 3 small functions into one large function", "Naming all functions 'helper1()', 'helper2()', 'helper3()'"]}, + {"type": "single_select", "id": "cs-org-q3", "question": "Sam notices the same 10 lines of code for formatting dates appears in 6 different files. What should Sam do to improve the codebase?", "correct_answer": "Create a format_date() function and call it from all 6 locations", "all_answers": ["Create a format_date() function and call it from all 6 locations", "Leave it because it already works", "Add a comment saying 'duplicated code' in each file", "Delete the date formatting since it's repetitive"]} + ] + } + ], + "prepost_questions": [ + {"id": "cs-func-pp-1a", "variant": "A", "lo_index": 0, "type": "single_select", "question": "What keyword defines a function in Python?", "correct_answer": "def", "all_answers": ["def", "function", "fun", "define"]}, + {"id": "cs-func-pp-1b", "variant": "B", "lo_index": 0, "type": "single_select", "question": "What is calling a function?", "correct_answer": "Running/executing the function's code", "all_answers": ["Running/executing the function's code", "Defining the function", "Deleting the function", "Naming the function"]}, + {"id": "cs-func-pp-2a", "variant": "A", "lo_index": 1, "type": "single_select", "question": "Why use functions instead of repeating code?", "correct_answer": "Easier maintenance and fewer bugs", "all_answers": ["Easier maintenance and fewer bugs", "Makes code longer", "Makes programs slower", "No real benefit"]}, + {"id": "cs-func-pp-2b", "variant": "B", "lo_index": 1, "type": "single_select", "question": "What happens when you fix repeated code?", "correct_answer": "Must fix it in every location", "all_answers": ["Must fix it in every location", "Automatically fixes everywhere", "Cannot fix it", "It fixes itself"]}, + {"id": "cs-func-pp-3a", "variant": "A", "lo_index": 2, "type": "single_select", "question": "In 'def greet(name)', what is 'name'?", "correct_answer": "A parameter", "all_answers": ["A parameter", "A return value", "The function name", "A keyword"]}, + {"id": "cs-func-pp-3b", "variant": "B", "lo_index": 2, "type": "single_select", "question": "Can functions have multiple parameters?", "correct_answer": "Yes", "all_answers": ["Yes", "No", "Only two", "Only in Python"]}, + {"id": "cs-func-pp-4a", "variant": "A", "lo_index": 3, "type": "single_select", "question": "What does 'return 42' do?", "correct_answer": "Sends 42 back to the caller", "all_answers": ["Sends 42 back to the caller", "Prints 42", "Creates variable 42", "Does nothing"]}, + {"id": "cs-func-pp-4b", "variant": "B", "lo_index": 3, "type": "single_select", "question": "What happens after a return statement?", "correct_answer": "The function stops executing", "all_answers": ["The function stops executing", "The function continues", "The whole program ends", "Nothing special"]}, + {"id": "cs-func-pp-5a", "variant": "A", "lo_index": 4, "type": "single_select", "question": "What is modularity?", "correct_answer": "Breaking code into independent pieces", "all_answers": ["Breaking code into independent pieces", "Making code longer", "Adding more bugs", "Writing without functions"]}, + {"id": "cs-func-pp-5b", "variant": "B", "lo_index": 4, "type": "single_select", "question": "Why is code reuse valuable?", "correct_answer": "Saves time and reduces errors", "all_answers": ["Saves time and reduces errors", "Makes code longer", "Adds complexity", "No value"]}, + {"id": "cs-func-pp-6a", "variant": "A", "lo_index": 5, "type": "single_select", "question": "Should function names describe what they do?", "correct_answer": "Yes, always", "all_answers": ["Yes, always", "No", "Only sometimes", "Names don't matter"]}, + {"id": "cs-func-pp-6b", "variant": "B", "lo_index": 5, "type": "single_select", "question": "What should you do with a complex task?", "correct_answer": "Break it into smaller functions", "all_answers": ["Break it into smaller functions", "Write one huge function", "Avoid coding it", "Give up"]} + ] + } + ] + } + ] +} diff --git a/examples/curriculum_courses/sushichef.py b/examples/curriculum_courses/sushichef.py new file mode 100644 index 00000000..efb7d5f5 --- /dev/null +++ b/examples/curriculum_courses/sushichef.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python +""" +Example sushichef demonstrating curriculum structure nodes (CourseNode, UnitNode, LessonNode). + +This script loads course content from content.json and creates a channel with: +- Two courses: Hummingbird Biology and Introductory Computing +- Each course has 3 units with 3 lessons per unit +- Each lesson has a KPUB document and an exercise +- Each unit has pre/post test questions with learning objectives + +Run with: + python sushichef.py --token=YOUR_TOKEN_HERE +""" +import json +import os +import tempfile +import zipfile + +from ricecooker.chefs import SushiChef +from ricecooker.classes.curriculum import LearningObjective +from ricecooker.classes.licenses import get_license +from ricecooker.classes.nodes import CourseNode +from ricecooker.classes.nodes import DocumentNode +from ricecooker.classes.nodes import ExerciseNode +from ricecooker.classes.nodes import LessonNode +from ricecooker.classes.nodes import UnitNode +from ricecooker.classes.questions import MultipleSelectQuestion +from ricecooker.classes.questions import SingleSelectQuestion +from ricecooker.classes.questions import VARIANT_A +from ricecooker.classes.questions import VARIANT_B + + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +CONTENT_FILE = os.path.join(SCRIPT_DIR, "content.json") + + +def load_content(): + """Load course content from JSON file.""" + with open(CONTENT_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def create_kpub(html_content, output_path): + """ + Create a KPUB file (zip archive with index.html). + + Args: + html_content: HTML string for the lesson content + output_path: Path where the .kpub file will be created + """ + full_html = f""" + + + + Lesson + + +{html_content} + +""" + + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("index.html", full_html.encode("utf-8")) + + +def create_question(question_data): + """ + Create a question object from JSON data. + + Args: + question_data: Dict with type, id, question, answers, etc. + + Returns: + SingleSelectQuestion or MultipleSelectQuestion instance + """ + if question_data["type"] == "single_select": + return SingleSelectQuestion( + id=question_data["id"], + question=question_data["question"], + correct_answer=question_data["correct_answer"], + all_answers=question_data["all_answers"], + ) + elif question_data["type"] == "multiple_select": + return MultipleSelectQuestion( + id=question_data["id"], + question=question_data["question"], + correct_answers=question_data["correct_answers"], + all_answers=question_data["all_answers"], + ) + else: + raise ValueError(f"Unknown question type: {question_data['type']}") + + +class CurriculumCoursesChef(SushiChef): + """ + Sushichef demonstrating curriculum structure with CourseNode, UnitNode, LessonNode. + """ + + channel_info = { + "CHANNEL_TITLE": "Curriculum Courses Example", + "CHANNEL_SOURCE_DOMAIN": "learningequality.org", + "CHANNEL_SOURCE_ID": "curriculum-courses-example", + "CHANNEL_LANGUAGE": "en", + "CHANNEL_DESCRIPTION": "Example channel demonstrating curriculum structure nodes", + } + + def construct_channel(self, **kwargs): + """Build channel structure from content.json.""" + channel = self.get_channel(**kwargs) + content = load_content() + + license = get_license("CC BY", copyright_holder="Learning Equality") + + for course_data in content["courses"]: + course_node = self._build_course(course_data, license) + channel.add_child(course_node) + + return channel + + def _build_course(self, course_data, license): + """Build a CourseNode from JSON data.""" + course = CourseNode( + source_id=course_data["source_id"], + title=course_data["title"], + description=course_data.get("description", ""), + ) + + for unit_data in course_data["units"]: + unit = self._build_unit(unit_data, license) + course.add_child(unit) + + return course + + def _build_unit(self, unit_data, license): + """Build a UnitNode with lessons and pre/post questions.""" + unit = UnitNode( + source_id=unit_data["source_id"], + title=unit_data["title"], + description=unit_data.get("description", ""), + ) + + # Create LearningObjective objects from the text strings + learning_objectives = [ + LearningObjective(text) for text in unit_data["learning_objectives"] + ] + + # Add lessons with their associated learning objectives + for lesson_data in unit_data["lessons"]: + lesson = self._build_lesson(lesson_data, license) + # Get the LearningObjective objects for this lesson + lesson_los = [ + learning_objectives[i] + for i in lesson_data["learning_objective_indices"] + ] + unit.add_child(lesson, lesson_los) + + # Add pre/post test questions + for q_data in unit_data["prepost_questions"]: + question = create_question(q_data) + variant = VARIANT_A if q_data["variant"] == "A" else VARIANT_B + # Get the LearningObjective for this question + question_los = [learning_objectives[q_data["lo_index"]]] + unit.add_question(question, variant, question_los) + + return unit + + def _build_lesson(self, lesson_data, license): + """Build a LessonNode with content document and exercise.""" + lesson = LessonNode( + source_id=lesson_data["source_id"], + title=lesson_data["title"], + ) + + # Create KPUB file for the lesson content + fd, kpub_path = tempfile.mkstemp(suffix=".kpub") + os.close(fd) + create_kpub(lesson_data["content"], kpub_path) + + # Add document node with KPUB content + doc_node = DocumentNode( + source_id=f"{lesson_data['source_id']}-doc", + title=f"{lesson_data['title']} - Reading", + license=license, + language="en", + uri=kpub_path, + ) + lesson.add_child(doc_node) + + # Add exercise node with practice questions + questions = [create_question(q) for q in lesson_data["exercise_questions"]] + exercise = ExerciseNode( + source_id=f"{lesson_data['source_id']}-exercise", + title=f"{lesson_data['title']} - Practice", + license=license, + language="en", + questions=questions, + exercise_data={ + "mastery_model": "m_of_n", + "m": min(3, len(questions)), + "n": len(questions), + "randomize": True, + }, + ) + lesson.add_child(exercise) + + return lesson + + +if __name__ == "__main__": + chef = CurriculumCoursesChef() + chef.main() diff --git a/ricecooker/classes/curriculum.py b/ricecooker/classes/curriculum.py new file mode 100644 index 00000000..9c59f6e4 --- /dev/null +++ b/ricecooker/classes/curriculum.py @@ -0,0 +1,42 @@ +""" +Curriculum-related classes for structured educational content. + +This module provides classes for representing curriculum concepts like +learning objectives, which can be associated with lessons and assessment +questions. +""" +import uuid + +# Fixed namespace for generating deterministic learning objective UUIDs. +# Same text will always produce the same UUID. +LEARNING_OBJECTIVE_NAMESPACE = uuid.UUID("b5e3f3e8-9c7a-4d6b-8f2e-1a5c9d8e7f6b") + + +class LearningObjective: + """ + Represents a learning objective that can be associated with lessons and questions. + + The ID is deterministically generated from the text using UUID5, so identical + text always produces the same ID. This prevents accidental duplicates. + + Attributes: + text (str): Human-readable description of the learning objective. + id (str): Deterministic UUID5 generated from the text. + metadata (dict): Optional metadata associated with the objective. + """ + + def __init__(self, text, metadata=None): + """Create a new LearningObjective with deterministic UUID5 from text.""" + if not text or not text.strip(): + raise ValueError("LearningObjective text must be non-empty") + self.text = text + self.id = uuid.uuid5(LEARNING_OBJECTIVE_NAMESPACE, text).hex + self.metadata = metadata or {} + + def to_dict(self): + """Serialize the learning objective for inclusion in channel data.""" + return { + "id": self.id, + "text": self.text, + "metadata": self.metadata, + } diff --git a/ricecooker/classes/nodes.py b/ricecooker/classes/nodes.py index adcdda02..fed5ceda 100644 --- a/ricecooker/classes/nodes.py +++ b/ricecooker/classes/nodes.py @@ -3,10 +3,13 @@ import re import uuid +from le_utils.constants import completion_criteria from le_utils.constants import content_kinds from le_utils.constants import exercises from le_utils.constants import format_presets from le_utils.constants import languages +from le_utils.constants import mastery_criteria +from le_utils.constants import modalities from le_utils.constants import roles from le_utils.constants.labels import accessibility_categories from le_utils.constants.labels import learning_activities @@ -19,6 +22,7 @@ from .. import config from ..exceptions import InvalidNodeException from ..utils.utils import is_valid_uuid_string +from .curriculum import LearningObjective from .files import ExtractedEPubThumbnailFile from .files import ExtractedHTMLZipThumbnailFile from .files import ExtractedPdfThumbnailFile @@ -26,6 +30,8 @@ from .files import SubtitleFile from .files import YouTubeSubtitleFile from .licenses import License +from .questions import VARIANT_A +from .questions import VARIANT_B from ricecooker.utils.pipeline.exceptions import ExpectedFileException from ricecooker.utils.pipeline.exceptions import InvalidFileException @@ -99,8 +105,11 @@ class Node(object): kind = None license = None language = None - kind = None valid = False + # Nodes with questions (ExerciseNode, UnitNode) set this True. + # This gates Node._validate() to allow a non-empty `questions` list, + # which TreeNode.to_dict() serializes for the Studio API. + _allows_questions = False def __init__( self, @@ -431,9 +440,10 @@ def _validate(self): # noqa: C901 self.kind != self.__class__.kind, f"{self.__class__.__name__} must have kind {self.__class__.kind}", ) - if self.kind != content_kinds.EXERCISE: + if not self._allows_questions: self._validate_values( - bool(self.questions), f"{self.kind} should not have questions" + bool(self.questions), + f"{self.__class__.__name__} should not have questions", ) self._validate_values(not isinstance(self.title, str), "Title is not a string") @@ -838,9 +848,12 @@ def pipeline(self): return self._pipeline def __str__(self): - metadata = "{0} {1}".format( - len(self.files), "file" if len(self.files) == 1 else "files" - ) + if len(self.files) == 0 and self.uri: + metadata = "uri: {}".format(self.uri) + else: + metadata = "{0} {1}".format( + len(self.files), "file" if len(self.files) == 1 else "files" + ) return "{title} ({kind}): {metadata}".format( title=self.title, kind=self.__class__.__name__, metadata=metadata ) @@ -1036,7 +1049,7 @@ class AudioNode(ContentNode): class DocumentNode(ContentNode): """Model representing documents in channel - Documents must be in PDF or ePub format + Documents must be in PDF, ePub, Bloom, or KPUB format Attributes: derive_thumbnail (bool): automatically generate thumbnail (optional) @@ -1049,6 +1062,7 @@ class DocumentNode(ContentNode): format_presets.DOCUMENT, format_presets.EPUB, format_presets.BLOOMPUB, + format_presets.KPUB_ZIP, ) def generate_thumbnail(self): @@ -1136,6 +1150,7 @@ class ExerciseNode(ContentNode): """ kind = content_kinds.EXERCISE + _allows_questions = True def __init__(self, *args, questions=None, exercise_data=None, **kwargs): self.questions = questions or [] @@ -1482,3 +1497,258 @@ def to_dict(self): # add alias for back-compatibility RemoteContentNode = StudioContentNode + + +class _CurriculumNode(TopicNode): + """ + Internal base class for curriculum-structured topic nodes (Course, Unit, Lesson). + + Provides common functionality for setting modality and validating child types. + Not intended to be instantiated directly. + + Attributes: + MODALITY: The modality constant from le_utils.constants.modalities + CHILD_CLASS: The allowed child node class + """ + + MODALITY = None # Subclasses must define + CHILD_CLASS = None # Subclasses must define + + def __init__(self, *args, **kwargs): + kwargs["extra_fields"] = kwargs.get("extra_fields", {}) + kwargs["extra_fields"]["options"] = kwargs["extra_fields"].get("options", {}) + kwargs["extra_fields"]["options"]["modality"] = self.MODALITY + super().__init__(*args, **kwargs) + + def _validate_child(self, node): + """Validate that node is an instance of the allowed child class.""" + if not isinstance(node, self.CHILD_CLASS): + raise InvalidNodeException( + f"{self.__class__.__name__} can only have {self.CHILD_CLASS.__name__} children" + ) + + def add_child(self, node): + """Add a child node after validating its type.""" + self._validate_child(node) + super().add_child(node) + + +class LessonNode(_CurriculumNode): + """ + Topic node representing a lesson within a unit. + + Lessons can only contain resource nodes (ContentNode subclasses like + VideoNode, DocumentNode, etc.), not other topic nodes. + + Attributes: + source_id (str): lesson's original id + title (str): lesson's title + description (str): description of lesson (optional) + thumbnail (str): local path or url to thumbnail image (optional) + """ + + MODALITY = modalities.LESSON + CHILD_CLASS = ContentNode + + +class UnitNode(_CurriculumNode): + """ + Topic node representing a unit within a course. + + Units can only contain LessonNodes as children. Units also manage + pre/post test questions and learning objectives. + + Attributes: + source_id (str): unit's original id + title (str): unit's title + description (str): description of unit (optional) + thumbnail (str): local path or url to thumbnail image (optional) + test_questions (list): list of (question, variant, learning_objectives) tuples + lesson_objectives (dict): mapping of source_id to list of LearningObjective + """ + + MODALITY = modalities.UNIT + CHILD_CLASS = LessonNode + _allows_questions = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_questions = [] # List of (question, variant, [LOs]) + self.lesson_objectives = {} # {source_id: [LOs]} + + def _validate_learning_objectives(self, learning_objectives): + """Validate that all items are valid LearningObjective instances.""" + if not learning_objectives: + raise InvalidNodeException("Must have at least one learning objective") + for lo in learning_objectives: + if not isinstance(lo, LearningObjective): + raise InvalidNodeException( + f"Expected LearningObjective, got {type(lo).__name__}" + ) + + def add_child(self, node, learning_objectives): + """ + Add a LessonNode with its associated learning objectives. + + Note: this intentionally has a different signature from + _CurriculumNode.add_child(node) — learning_objectives is required. + This is safe because add_child is only called during channel + construction by chef code, never by internal tree-walking code. + + Args: + node: LessonNode to add + learning_objectives: List of LearningObjective instances + """ + self._validate_learning_objectives(learning_objectives) + if node.source_id in self.lesson_objectives: + raise InvalidNodeException( + f"Duplicate source_id '{node.source_id}' in {self.__class__.__name__}" + ) + self.lesson_objectives[node.source_id] = learning_objectives + super().add_child(node) + + def add_question(self, question, variant, learning_objectives): + """ + Add a pre/post test question. + + Args: + question: Question instance (same types as ExerciseNode) + variant: VARIANT_A or VARIANT_B + learning_objectives: List of LearningObjective instances + """ + if variant not in (VARIANT_A, VARIANT_B): + raise InvalidNodeException("variant must be VARIANT_A or VARIANT_B") + self._validate_learning_objectives(learning_objectives) + self.test_questions.append((question, variant, learning_objectives)) + + def _validate(self): + """Validate the unit including question balance and LO matching.""" + # Single pass: validate questions and partition by variant + variant_a = [] + variant_b = [] + for question, variant, los in self.test_questions: + self._validate_values( + not question.validate(), "UnitNode has invalid question" + ) + if variant == VARIANT_A: + variant_a.append((question, los)) + else: + variant_b.append((question, los)) + + # Minimum 2 questions per variant + self._validate_values( + len(variant_a) < 2, "Must have at least 2 VARIANT_A questions" + ) + self._validate_values( + len(variant_b) < 2, "Must have at least 2 VARIANT_B questions" + ) + + # Equal total counts + self._validate_values( + len(variant_a) != len(variant_b), + "VARIANT_A and VARIANT_B must have equal question counts", + ) + + # LO sets must match + lesson_los = {lo.id for los in self.lesson_objectives.values() for lo in los} + question_los = {lo.id for _, los in variant_a + variant_b for lo in los} + self._validate_values( + lesson_los != question_los, + "Learning objectives on lessons must match those on questions", + ) + + # Each LO equally represented across variants and across LOs + lo_totals = {} + for lo_id in lesson_los: + a_count = sum( + 1 for _, los in variant_a if any(lo.id == lo_id for lo in los) + ) + b_count = sum( + 1 for _, los in variant_b if any(lo.id == lo_id for lo in los) + ) + self._validate_values( + a_count != b_count, + "Learning objective must have equal questions in each variant", + ) + lo_totals[lo_id] = a_count + b_count + + # Each LO must have the same total number of questions + if lo_totals: + expected = next(iter(lo_totals.values())) + self._validate_values( + any(c != expected for c in lo_totals.values()), + "Each learning objective must have the same total number of questions", + ) + + return super()._validate() + + def _get_mastery_criteria(self): + """Build mastery criteria dict for pre/post test.""" + all_ids = [q.assessment_id for q, _, _ in self.test_questions] + a_ids = [q.assessment_id for q, v, _ in self.test_questions if v == VARIANT_A] + b_ids = [q.assessment_id for q, v, _ in self.test_questions if v == VARIANT_B] + + return { + "mastery_model": mastery_criteria.PRE_POST_TEST, + "pre_post_test": { + "assessment_item_ids": all_ids, + "version_a_item_ids": a_ids, + "version_b_item_ids": b_ids, + }, + } + + def _get_learning_objectives_data(self): + """Build learning objectives structure per le_utils schema.""" + # Collect unique LOs from lesson_objectives + # (validation guarantees match with questions) + all_los = {lo.id: lo for los in self.lesson_objectives.values() for lo in los} + + return { + "learning_objectives": [lo.to_dict() for lo in all_los.values()], + "assessment_objectives": { + q.assessment_id: [lo.id for lo in los] + for q, _, los in self.test_questions + }, + "lesson_objectives": { + child.get_node_id().hex: [ + lo.id for lo in self.lesson_objectives[child.source_id] + ] + for child in self.children + if child.source_id in self.lesson_objectives + }, + } + + def to_dict(self): + """Serialize UnitNode including mastery model and learning objectives in options.""" + result = super().to_dict() + + # Parse extra_fields, add mastery model and learning objectives to options + extra_fields = json.loads(result["extra_fields"]) + extra_fields["options"]["completion_criteria"] = { + "model": completion_criteria.MASTERY, + "threshold": self._get_mastery_criteria(), + } + extra_fields["options"].update(self._get_learning_objectives_data()) + result["extra_fields"] = json.dumps(extra_fields) + + # Serialize test questions + result["questions"] = [q.to_dict() for q, _, _ in self.test_questions] + + return result + + +class CourseNode(_CurriculumNode): + """ + Topic node representing a course. + + Courses can only contain UnitNodes as children. + + Attributes: + source_id (str): course's original id + title (str): course's title + description (str): description of course (optional) + thumbnail (str): local path or url to thumbnail image (optional) + """ + + MODALITY = modalities.COURSE + CHILD_CLASS = UnitNode diff --git a/ricecooker/classes/questions.py b/ricecooker/classes/questions.py index 4eb1eb8c..b795f931 100644 --- a/ricecooker/classes/questions.py +++ b/ricecooker/classes/questions.py @@ -30,6 +30,10 @@ # match protocol:{{path}} either wrapped in parentheses or quotes (original regex) MARKDOWN_IMAGE_REGEX = r"!\[([^\]]+)?\]\(([^\)]+?)\)" # match ![{{smth}}]({{url}}) +# Pre/post test variant constants for UnitNode questions +VARIANT_A = "A" +VARIANT_B = "B" + class BaseQuestion: """Base model representing exercise questions @@ -69,6 +73,11 @@ def __init__( self.randomize = randomize self.id = uuid.uuid5(uuid.NAMESPACE_DNS, id) + @property + def assessment_id(self): + """Return the assessment ID as a hex string.""" + return self.id.hex + def truncate_fields(self): if self.source_url and len(self.source_url) > config.MAX_SOURCE_URL_LENGTH: config.print_truncate( @@ -82,7 +91,7 @@ def to_dict(self): Returns: dict of node's data """ return { - "assessment_id": self.id.hex, + "assessment_id": self.assessment_id, "type": self.question_type, "files": [ f.to_dict() for f in filter(lambda x: x and x.filename, self.files) diff --git a/ricecooker/commands.py b/ricecooker/commands.py index 4d7b505a..5a2766d3 100644 --- a/ricecooker/commands.py +++ b/ricecooker/commands.py @@ -395,11 +395,21 @@ def walk_tree(parents_path, subtree): def attach(parent, node_path): if len(node_path) == 1: # leaf node - parent.add_child(node_path[0]) + try: + parent.add_child(node_path[0]) + except TypeError: + raise NotImplementedError( + "--sample mode is not supported for channels with curriculum structure nodes" + ) else: child = node_path[0] if not any(c.source_id == child.source_id for c in parent.children): - parent.add_child(child) + try: + parent.add_child(child) + except TypeError: + raise NotImplementedError( + "--sample mode is not supported for channels with curriculum structure nodes" + ) attach(child, node_path[1:]) for node_path in sample_paths: diff --git a/ricecooker/utils/pipeline/convert.py b/ricecooker/utils/pipeline/convert.py index 4c9e209b..2c3c729a 100644 --- a/ricecooker/utils/pipeline/convert.py +++ b/ricecooker/utils/pipeline/convert.py @@ -236,6 +236,31 @@ def read_file_from_archive(self, zf, filepath): f"File {zf.filename} is not a valid {self.FILE_TYPE} file, {filepath} is missing." ) + def _validate_index_html_body(self, zf, path): + """Validate that index.html exists and has a non-empty body.""" + index_html = self.read_file_from_archive(zf, "index.html") + try: + dom = html5lib.parse(index_html, namespaceHTMLElements=False) + body = dom.find("body") + if body is None: + raise InvalidFileException( + f"File {path} is not a valid {self.FILE_TYPE} file, index.html is missing a body element." + ) + # Check that the body has at least one child element + # for some reason it seems like comments don't get a string tag attribute + body_children = [ + c for c in body.iter() if isinstance(c.tag, str) and c.tag != "body" + ] + if not (body.text and body.text.strip()) and not body_children: + raise InvalidFileException( + f"File {path} is not a valid {self.FILE_TYPE} file, index.html is empty." + ) + return dom + except ParseError: + raise InvalidFileException( + f"File {path} is not a valid {self.FILE_TYPE} file, index.html is not well-formed." + ) + def _read_and_compress_archive_file( self, filepath, reader, audio_settings=None, video_settings=None, ext=None ): @@ -291,28 +316,7 @@ class HTML5ConversionHandler(ArchiveProcessingBaseHandler): def validate_archive(self, path: str): with self.open_and_verify_archive(path) as zf: - # Check index.html exists and is valid HTML - index_html = self.read_file_from_archive(zf, "index.html") - try: - dom = html5lib.parse(index_html, namespaceHTMLElements=False) - body = dom.find("body") - if body is None: - raise InvalidFileException( - f"File {path} is not a valid HTML5 file, index.html is missing a body element." - ) - # Check that the body has at least one child element - # for some reason it seems like comments don't get a string tag attribute - body_children = [ - c for c in body.iter() if isinstance(c.tag, str) and c.tag != "body" - ] - if not body.text.strip() and not body_children: - raise InvalidFileException( - f"File {path} is not a valid HTML5 file, index.html is empty." - ) - except ParseError: - raise InvalidFileException( - f"File {path} is not a valid HTML5 file, index.html is not well-formed." - ) + self._validate_index_html_body(zf, path) class H5PConversionHandler(ArchiveProcessingBaseHandler): @@ -404,6 +408,34 @@ def validate_archive(self, path: str): self._validate_opf(zf, path, opf_path) +class KPUBConversionHandler(ArchiveProcessingBaseHandler): + + EXTENSIONS = {file_formats.HTML5_ARTICLE} + FILE_TYPE = "KPUB" + + def validate_archive(self, path: str): + with self.open_and_verify_archive(path) as zf: + dom = self._validate_index_html_body(zf, path) + + # Check for inline ", + } + ) + + def test_inline_styles_allowed(self): + self._validate( + {"index.html": '

Hello

'} + ) + + def test_images_allowed(self): + png_data = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" + ) + self._validate( + { + "index.html": '', + "image.png": png_data, + } + ) + + def test_empty_body_rejected(self): + with pytest.raises(InvalidFileException, match="(?i)empty"): + self._validate({"index.html": ""}) + + def test_whitespace_only_body_rejected(self): + with pytest.raises(InvalidFileException, match="(?i)empty"): + self._validate({"index.html": " \n "}) + + def test_invalid_zip(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.kpub") + with open(path, "wb") as f: + f.write(b"not a zip file") + with pytest.raises(InvalidFileException, match="(?i)zip"): + KPUBConversionHandler().validate_archive(path) diff --git a/tests/pipeline/test_extract_metadata.py b/tests/pipeline/test_extract_metadata.py new file mode 100644 index 00000000..c0570842 --- /dev/null +++ b/tests/pipeline/test_extract_metadata.py @@ -0,0 +1,38 @@ +"""Tests for metadata extraction in the file pipeline.""" +import os +import tempfile +import zipfile + +from le_utils.constants import content_kinds +from le_utils.constants import format_presets + +from ricecooker.utils.pipeline import FilePipeline + + +def _create_archive(path, files_dict): + """Helper to create a zip archive with given files.""" + with zipfile.ZipFile(path, "w") as zf: + for filename, content in files_dict.items(): + if isinstance(content, str): + content = content.encode("utf-8") + zf.writestr(filename, content) + + +class TestKPUBMetadataExtraction: + """Tests for KPUB metadata extraction.""" + + def test_kpub_metadata(self): + """KPUB files should be detected with correct preset and kind.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "test.kpub") + _create_archive( + path, + {"index.html": "

Hello

"}, + ) + + pipeline = FilePipeline() + result = pipeline.execute(path)[0] + + assert result.preset == format_presets.KPUB_ZIP + assert result.content_node_metadata is not None + assert result.content_node_metadata["kind"] == content_kinds.DOCUMENT diff --git a/tests/test_curriculum.py b/tests/test_curriculum.py new file mode 100644 index 00000000..f392c1ee --- /dev/null +++ b/tests/test_curriculum.py @@ -0,0 +1,672 @@ +"""Tests for curriculum nodes and learning objectives.""" +import json +import re +import uuid + +import pytest +from le_utils.constants import content_kinds +from le_utils.constants import licenses +from le_utils.constants import modalities + +from ricecooker.classes.curriculum import LEARNING_OBJECTIVE_NAMESPACE +from ricecooker.classes.curriculum import LearningObjective +from ricecooker.classes.nodes import ChannelNode +from ricecooker.classes.nodes import CourseNode +from ricecooker.classes.nodes import LessonNode +from ricecooker.classes.nodes import TopicNode +from ricecooker.classes.nodes import UnitNode +from ricecooker.classes.nodes import VideoNode +from ricecooker.classes.questions import SingleSelectQuestion +from ricecooker.classes.questions import VARIANT_A +from ricecooker.classes.questions import VARIANT_B +from ricecooker.exceptions import InvalidNodeException + + +def make_question(question_id): + """Helper to create a simple question for testing.""" + return SingleSelectQuestion( + id=question_id, + question="What is 1 + 1?", + correct_answer="2", + all_answers=["1", "2", "3", "4"], + ) + + +class TestLearningObjective: + """Tests for the LearningObjective class.""" + + def test_creates_with_text(self): + """LearningObjective can be created with just text.""" + lo = LearningObjective("Understand addition") + assert lo.text == "Understand addition" + + def test_generates_uuid5_from_text(self): + """LearningObjective generates a deterministic UUID5 from text.""" + lo = LearningObjective("Understand addition") + expected_id = uuid.uuid5( + LEARNING_OBJECTIVE_NAMESPACE, "Understand addition" + ).hex + assert lo.id == expected_id + + def test_id_is_compact_hex_format(self): + """LearningObjective ID must be compact hex (32 chars, no dashes) per le_utils schema.""" + lo = LearningObjective("Understand addition") + # Must match le_utils schema pattern: ^[0-9a-f]{32}$ + assert re.match( + r"^[0-9a-f]{32}$", lo.id + ), f"ID '{lo.id}' is not compact hex format" + + def test_same_text_produces_same_id(self): + """Identical text produces identical IDs (deterministic).""" + lo1 = LearningObjective("Understand addition") + lo2 = LearningObjective("Understand addition") + assert lo1.id == lo2.id + + def test_different_text_produces_different_id(self): + """Different text produces different IDs.""" + lo1 = LearningObjective("Understand addition") + lo2 = LearningObjective("Understand subtraction") + assert lo1.id != lo2.id + + def test_metadata_defaults_to_empty_dict(self): + """Metadata defaults to empty dict if not provided.""" + lo = LearningObjective("Understand addition") + assert lo.metadata == {} + + def test_metadata_can_be_provided(self): + """Metadata can be passed at construction.""" + metadata = {"difficulty": "easy", "grade": 3} + lo = LearningObjective("Understand addition", metadata=metadata) + assert lo.metadata == metadata + + def test_to_dict_returns_correct_structure(self): + """to_dict returns the expected structure for serialization.""" + metadata = {"difficulty": "easy"} + lo = LearningObjective("Understand addition", metadata=metadata) + result = lo.to_dict() + + assert result["id"] == lo.id + assert result["text"] == "Understand addition" + assert result["metadata"] == metadata + + def test_rejects_empty_text(self): + """LearningObjective raises ValueError for empty text.""" + with pytest.raises(ValueError, match="text must be non-empty"): + LearningObjective("") + + def test_rejects_whitespace_only_text(self): + """LearningObjective raises ValueError for whitespace-only text.""" + with pytest.raises(ValueError, match="text must be non-empty"): + LearningObjective(" ") + + def test_rejects_none_text(self): + """LearningObjective raises ValueError for None text.""" + with pytest.raises(ValueError, match="text must be non-empty"): + LearningObjective(None) + + +class TestCourseNode: + """Tests for the CourseNode class.""" + + def test_creates_with_required_fields(self): + """CourseNode can be created with source_id and title.""" + course = CourseNode(source_id="course-1", title="Math Course") + assert course.title == "Math Course" + assert course.source_id == "course-1" + + def test_sets_modality_to_course(self): + """CourseNode sets modality to COURSE in extra_fields.""" + course = CourseNode(source_id="course-1", title="Math Course") + assert course.extra_fields["options"]["modality"] == modalities.COURSE + + def test_kind_is_topic(self): + """CourseNode has kind TOPIC (it's a subclass of TopicNode).""" + course = CourseNode(source_id="course-1", title="Math Course") + assert course.kind == content_kinds.TOPIC + + def test_accepts_unit_node_as_child(self): + """CourseNode accepts UnitNode as child.""" + course = CourseNode(source_id="course-1", title="Math Course") + unit = UnitNode(source_id="unit-1", title="Unit 1") + course.add_child(unit) + assert unit in course.children + + def test_rejects_topic_node_as_child(self): + """CourseNode rejects TopicNode as child.""" + course = CourseNode(source_id="course-1", title="Math Course") + topic = TopicNode(source_id="topic-1", title="Topic 1") + with pytest.raises(InvalidNodeException): + course.add_child(topic) + + def test_rejects_lesson_node_as_child(self): + """CourseNode rejects LessonNode as direct child.""" + course = CourseNode(source_id="course-1", title="Math Course") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + with pytest.raises(InvalidNodeException): + course.add_child(lesson) + + def test_rejects_content_node_as_child(self): + """CourseNode rejects ContentNode (e.g., VideoNode) as child.""" + course = CourseNode(source_id="course-1", title="Math Course") + video = VideoNode( + source_id="video-1", + title="Video 1", + license=licenses.PUBLIC_DOMAIN, + ) + with pytest.raises(InvalidNodeException): + course.add_child(video) + + +class TestLessonNode: + """Tests for the LessonNode class.""" + + def test_creates_with_required_fields(self): + """LessonNode can be created with source_id and title.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + assert lesson.title == "Addition Lesson" + assert lesson.source_id == "lesson-1" + + def test_sets_modality_to_lesson(self): + """LessonNode sets modality to LESSON in extra_fields.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + assert lesson.extra_fields["options"]["modality"] == modalities.LESSON + + def test_kind_is_topic(self): + """LessonNode has kind TOPIC (it's a subclass of TopicNode).""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + assert lesson.kind == content_kinds.TOPIC + + def test_accepts_video_node_as_child(self): + """LessonNode accepts VideoNode (ContentNode) as child.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + video = VideoNode( + source_id="video-1", + title="Video 1", + license=licenses.PUBLIC_DOMAIN, + ) + lesson.add_child(video) + assert video in lesson.children + + def test_rejects_topic_node_as_child(self): + """LessonNode rejects TopicNode as child.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + topic = TopicNode(source_id="topic-1", title="Topic 1") + with pytest.raises(InvalidNodeException): + lesson.add_child(topic) + + def test_rejects_unit_node_as_child(self): + """LessonNode rejects UnitNode as child.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + unit = UnitNode(source_id="unit-1", title="Unit 1") + with pytest.raises(InvalidNodeException): + lesson.add_child(unit) + + def test_rejects_course_node_as_child(self): + """LessonNode rejects CourseNode as child.""" + lesson = LessonNode(source_id="lesson-1", title="Addition Lesson") + course = CourseNode(source_id="course-1", title="Course 1") + with pytest.raises(InvalidNodeException): + lesson.add_child(course) + + def test_rejects_lesson_node_as_child(self): + """LessonNode rejects another LessonNode as child.""" + lesson1 = LessonNode(source_id="lesson-1", title="Lesson 1") + lesson2 = LessonNode(source_id="lesson-2", title="Lesson 2") + with pytest.raises(InvalidNodeException): + lesson1.add_child(lesson2) + + +class TestUnitNode: + """Tests for the UnitNode class.""" + + def test_creates_with_required_fields(self): + """UnitNode can be created with source_id and title.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + assert unit.title == "Math Unit" + assert unit.source_id == "unit-1" + + def test_sets_modality_to_unit(self): + """UnitNode sets modality to UNIT in extra_fields.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + assert unit.extra_fields["options"]["modality"] == modalities.UNIT + + def test_kind_is_topic(self): + """UnitNode has kind TOPIC (it's a subclass of TopicNode).""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + assert unit.kind == content_kinds.TOPIC + + def test_has_empty_test_questions(self): + """UnitNode initializes with empty test_questions list.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + assert unit.test_questions == [] + + def test_has_empty_lesson_objectives(self): + """UnitNode initializes with empty lesson_objectives dict.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + assert unit.lesson_objectives == {} + + +class TestUnitNodeAddChild: + """Tests for UnitNode.add_child with learning_objectives.""" + + def test_requires_learning_objectives_parameter(self): + """UnitNode.add_child requires learning_objectives — unlike other _CurriculumNodes. + + This documents the intentional API difference: UnitNode.add_child has a + different signature from CourseNode/LessonNode. Code that polymorphically + calls node.add_child(child) will get TypeError on a UnitNode. + """ + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + with pytest.raises(TypeError, match="learning_objectives"): + unit.add_child(lesson) + + def test_accepts_lesson_node_with_learning_objectives(self): + """UnitNode accepts LessonNode with learning_objectives.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + lo = LearningObjective("Understand addition") + unit.add_child(lesson, [lo]) + assert lesson in unit.children + + def test_stores_lesson_objectives_by_source_id(self): + """UnitNode stores learning objectives keyed by source_id, not object identity.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + lo = LearningObjective("Understand addition") + unit.add_child(lesson, [lo]) + assert unit.lesson_objectives["lesson-1"] == [lo] + + def test_rejects_lesson_without_learning_objectives(self): + """UnitNode rejects LessonNode without learning_objectives.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + with pytest.raises( + InvalidNodeException, match="Must have at least one learning objective" + ): + unit.add_child(lesson, []) + + def test_rejects_lesson_with_none_learning_objectives(self): + """UnitNode rejects LessonNode with None learning_objectives.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + with pytest.raises( + InvalidNodeException, match="Must have at least one learning objective" + ): + unit.add_child(lesson, None) + + def test_rejects_topic_node_as_child(self): + """UnitNode rejects TopicNode as child.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + topic = TopicNode(source_id="topic-1", title="Topic 1") + lo = LearningObjective("Understand addition") + with pytest.raises(InvalidNodeException): + unit.add_child(topic, [lo]) + + def test_rejects_content_node_as_child(self): + """UnitNode rejects ContentNode as child.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + video = VideoNode( + source_id="video-1", + title="Video 1", + license=licenses.PUBLIC_DOMAIN, + ) + lo = LearningObjective("Understand addition") + with pytest.raises(InvalidNodeException): + unit.add_child(video, [lo]) + + def test_rejects_non_learning_objective_in_list(self): + """UnitNode rejects non-LearningObjective items in learning_objectives.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + with pytest.raises(InvalidNodeException): + unit.add_child(lesson, ["not a LearningObjective"]) + + def test_rejects_invalid_learning_objective(self): + """LearningObjective with empty text cannot be constructed.""" + with pytest.raises(ValueError, match="text must be non-empty"): + LearningObjective("") + + def test_rejects_duplicate_source_id(self): + """UnitNode rejects a second LessonNode with the same source_id.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson1 = LessonNode(source_id="lesson-1", title="Lesson 1") + lesson2 = LessonNode(source_id="lesson-1", title="Lesson 1 copy") + unit.add_child(lesson1, [lo]) + with pytest.raises(InvalidNodeException): + unit.add_child(lesson2, [lo]) + + +class TestUnitNodeAddQuestion: + """Tests for UnitNode.add_question method.""" + + def test_adds_question_with_variant_a(self): + """UnitNode accepts question with VARIANT_A.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + q = make_question("q1") + unit.add_question(q, VARIANT_A, [lo]) + assert len(unit.test_questions) == 1 + assert unit.test_questions[0] == (q, VARIANT_A, [lo]) + + def test_adds_question_with_variant_b(self): + """UnitNode accepts question with VARIANT_B.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + q = make_question("q1") + unit.add_question(q, VARIANT_B, [lo]) + assert len(unit.test_questions) == 1 + assert unit.test_questions[0] == (q, VARIANT_B, [lo]) + + def test_rejects_invalid_variant(self): + """UnitNode rejects question with invalid variant.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + q = make_question("q1") + with pytest.raises(InvalidNodeException): + unit.add_question(q, "C", [lo]) + + def test_rejects_question_without_learning_objectives(self): + """UnitNode rejects question without learning objectives.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + q = make_question("q1") + with pytest.raises( + InvalidNodeException, match="Must have at least one learning objective" + ): + unit.add_question(q, VARIANT_A, []) + + def test_rejects_non_learning_objective_in_question(self): + """UnitNode rejects non-LearningObjective items in add_question.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + q = make_question("q1") + with pytest.raises( + InvalidNodeException, match="Expected LearningObjective, got str" + ): + unit.add_question(q, VARIANT_A, ["not a LearningObjective"]) + + def test_rejects_invalid_learning_objective_in_question(self): + """UnitNode rejects invalid LearningObjective in add_question.""" + with pytest.raises(ValueError, match="text must be non-empty"): + LearningObjective("") # Empty text is invalid + + +class TestUnitNodeValidation: + """Tests for UnitNode validation rules.""" + + def _create_valid_unit(self): + """Helper to create a valid unit with balanced questions.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + unit.add_child(lesson, [lo]) + + # Add 2 questions per variant for the same LO + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_A, [lo]) + unit.add_question(make_question("q3"), VARIANT_B, [lo]) + unit.add_question(make_question("q4"), VARIANT_B, [lo]) + return unit + + def test_validates_valid_unit(self): + """Valid unit passes validation.""" + unit = self._create_valid_unit() + assert unit.validate() is True + + def test_fails_with_less_than_2_variant_a_questions(self): + """Unit fails validation with less than 2 VARIANT_A questions.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + unit.add_child(lesson, [lo]) + + # Only 1 VARIANT_A question + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_B, [lo]) + unit.add_question(make_question("q3"), VARIANT_B, [lo]) + + with pytest.raises( + InvalidNodeException, match="Must have at least 2 VARIANT_A questions" + ): + unit.validate() + + def test_fails_with_less_than_2_variant_b_questions(self): + """Unit fails validation with less than 2 VARIANT_B questions.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + unit.add_child(lesson, [lo]) + + # Only 1 VARIANT_B question + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_A, [lo]) + unit.add_question(make_question("q3"), VARIANT_B, [lo]) + + with pytest.raises( + InvalidNodeException, match="Must have at least 2 VARIANT_B questions" + ): + unit.validate() + + def test_fails_with_unequal_variant_counts(self): + """Unit fails validation when variant counts are unequal.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + unit.add_child(lesson, [lo]) + + # 3 VARIANT_A, 2 VARIANT_B + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_A, [lo]) + unit.add_question(make_question("q3"), VARIANT_A, [lo]) + unit.add_question(make_question("q4"), VARIANT_B, [lo]) + unit.add_question(make_question("q5"), VARIANT_B, [lo]) + + with pytest.raises( + InvalidNodeException, + match="VARIANT_A and VARIANT_B must have equal question counts", + ): + unit.validate() + + def test_fails_when_lesson_los_dont_match_question_los(self): + """Unit fails when lesson LOs don't match question LOs.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo1 = LearningObjective("Understand addition") + lo2 = LearningObjective("Understand subtraction") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + + # Lesson has lo1, but questions have lo2 + unit.add_child(lesson, [lo1]) + unit.add_question(make_question("q1"), VARIANT_A, [lo2]) + unit.add_question(make_question("q2"), VARIANT_A, [lo2]) + unit.add_question(make_question("q3"), VARIANT_B, [lo2]) + unit.add_question(make_question("q4"), VARIANT_B, [lo2]) + + with pytest.raises( + InvalidNodeException, + match="Learning objectives on lessons must match those on questions", + ): + unit.validate() + + def test_fails_when_lo_not_balanced_across_variants(self): + """Unit fails when LO has unequal questions in each variant.""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo1 = LearningObjective("Understand addition") + lo2 = LearningObjective("Understand subtraction") + lesson1 = LessonNode(source_id="lesson-1", title="Lesson 1") + lesson2 = LessonNode(source_id="lesson-2", title="Lesson 2") + unit.add_child(lesson1, [lo1]) + unit.add_child(lesson2, [lo2]) + + # lo1: 2 in A, 1 in B (unbalanced) + # lo2: 0 in A, 1 in B (unbalanced) + unit.add_question(make_question("q1"), VARIANT_A, [lo1]) + unit.add_question(make_question("q2"), VARIANT_A, [lo1]) + unit.add_question(make_question("q3"), VARIANT_B, [lo1]) + unit.add_question(make_question("q4"), VARIANT_B, [lo2]) + + with pytest.raises( + InvalidNodeException, + match="Learning objective must have equal questions in each variant", + ): + unit.validate() + + def test_fails_when_los_have_unequal_total_questions(self): + """Unit fails when LOs have different total question counts. + + Even if per-variant balance is correct for each LO, each LO must + have the same total number of assessment items across both variants. + """ + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo1 = LearningObjective("Understand addition") + lo2 = LearningObjective("Understand subtraction") + lesson1 = LessonNode(source_id="lesson-1", title="Lesson 1") + lesson2 = LessonNode(source_id="lesson-2", title="Lesson 2") + unit.add_child(lesson1, [lo1]) + unit.add_child(lesson2, [lo2]) + + # lo1: 2 in A, 2 in B = 4 total (per-variant balanced) + # lo2: 1 in A, 1 in B = 2 total (per-variant balanced) + # But lo1 has 4 questions and lo2 has 2 — unequal across LOs + unit.add_question(make_question("q1"), VARIANT_A, [lo1]) + unit.add_question(make_question("q2"), VARIANT_A, [lo1]) + unit.add_question(make_question("q3"), VARIANT_A, [lo2]) + unit.add_question(make_question("q4"), VARIANT_B, [lo1]) + unit.add_question(make_question("q5"), VARIANT_B, [lo1]) + unit.add_question(make_question("q6"), VARIANT_B, [lo2]) + + with pytest.raises( + InvalidNodeException, + match="Each learning objective must have the same total number of questions", + ): + unit.validate() + + +class TestUnitNodeSerialization: + """Tests for UnitNode serialization methods.""" + + def _create_valid_unit_with_parent_chain(self): + """Helper to create a valid unit with proper parent chain for node ID generation.""" + # Create parent chain: Channel -> Course -> Unit -> Lesson + channel = ChannelNode( + source_id="test-channel", + source_domain="test.com", + title="Test Channel", + language="en", + ) + course = CourseNode(source_id="course-1", title="Math Course") + unit = UnitNode(source_id="unit-1", title="Math Unit") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + + lo = LearningObjective("Understand addition") + + # Build hierarchy + channel.add_child(course) + course.add_child(unit) + unit.add_child(lesson, [lo]) + + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_A, [lo]) + unit.add_question(make_question("q3"), VARIANT_B, [lo]) + unit.add_question(make_question("q4"), VARIANT_B, [lo]) + return unit, lo, lesson + + def _create_valid_unit(self): + """Helper to create a valid unit without parent chain (for tests that don't need it).""" + unit = UnitNode(source_id="unit-1", title="Math Unit") + lo = LearningObjective("Understand addition") + lesson = LessonNode(source_id="lesson-1", title="Lesson 1") + unit.add_child(lesson, [lo]) + + unit.add_question(make_question("q1"), VARIANT_A, [lo]) + unit.add_question(make_question("q2"), VARIANT_A, [lo]) + unit.add_question(make_question("q3"), VARIANT_B, [lo]) + unit.add_question(make_question("q4"), VARIANT_B, [lo]) + return unit, lo, lesson + + def test_get_mastery_criteria_returns_correct_structure(self): + """_get_mastery_criteria returns correct pre_post_test structure.""" + unit, lo, lesson = self._create_valid_unit() + result = unit._get_mastery_criteria() + + assert result["mastery_model"] == "pre_post_test" + assert "pre_post_test" in result + assert len(result["pre_post_test"]["assessment_item_ids"]) == 4 + assert len(result["pre_post_test"]["version_a_item_ids"]) == 2 + assert len(result["pre_post_test"]["version_b_item_ids"]) == 2 + + def test_get_learning_objectives_data_returns_correct_structure(self): + """_get_learning_objectives_data returns correct structure.""" + unit, lo, lesson = self._create_valid_unit_with_parent_chain() + result = unit._get_learning_objectives_data() + + assert "learning_objectives" in result + assert len(result["learning_objectives"]) == 1 + assert result["learning_objectives"][0]["id"] == lo.id + assert result["learning_objectives"][0]["text"] == lo.text + + assert "assessment_objectives" in result + assert len(result["assessment_objectives"]) == 4 + + assert "lesson_objectives" in result + assert len(result["lesson_objectives"]) == 1 + + def test_to_dict_extra_fields_contains_mastery_model_in_options(self): + """to_dict output includes mastery model data in extra_fields['options']['completion_criteria'].""" + unit, lo, lesson = self._create_valid_unit_with_parent_chain() + + result = unit.to_dict() + extra_fields = json.loads(result["extra_fields"]) + options = extra_fields.get("options", {}) + completion_criteria = options.get("completion_criteria", {}) + + assert completion_criteria.get("model") == "mastery" + threshold = completion_criteria.get("threshold", {}) + assert threshold.get("mastery_model") == "pre_post_test" + assert "pre_post_test" in threshold + assert len(threshold["pre_post_test"]["assessment_item_ids"]) == 4 + assert len(threshold["pre_post_test"]["version_a_item_ids"]) == 2 + assert len(threshold["pre_post_test"]["version_b_item_ids"]) == 2 + + def test_to_dict_extra_fields_contains_learning_objectives_in_options(self): + """to_dict output includes learning objectives data in extra_fields['options'].""" + unit, lo, lesson = self._create_valid_unit_with_parent_chain() + + result = unit.to_dict() + extra_fields = json.loads(result["extra_fields"]) + options = extra_fields.get("options", {}) + + assert "learning_objectives" in options + assert len(options["learning_objectives"]) == 1 + assert options["learning_objectives"][0]["id"] == lo.id + + assert "assessment_objectives" in options + assert len(options["assessment_objectives"]) == 4 + + assert "lesson_objectives" in options + assert len(options["lesson_objectives"]) == 1 + + def test_get_learning_objectives_data_validates_against_le_utils_schema(self): + """_get_learning_objectives_data output must conform to le_utils schema.""" + from le_utils.constants.learning_objectives import SCHEMA + + unit, lo, lesson = self._create_valid_unit_with_parent_chain() + result = unit._get_learning_objectives_data() + + # Validate learning_objectives IDs match hex-uuid pattern + hex_uuid_pattern = SCHEMA["definitions"]["hex-uuid"]["pattern"] + for obj in result["learning_objectives"]: + assert re.match( + hex_uuid_pattern, obj["id"] + ), f"Learning objective ID '{obj['id']}' doesn't match schema pattern {hex_uuid_pattern}" + + # Validate assessment_objectives keys match hex-uuid pattern + for assessment_id in result["assessment_objectives"].keys(): + assert re.match( + hex_uuid_pattern, assessment_id + ), f"Assessment ID '{assessment_id}' doesn't match schema pattern {hex_uuid_pattern}" + + # Validate lesson_objectives keys match hex-uuid pattern + for lesson_id in result["lesson_objectives"].keys(): + assert re.match( + hex_uuid_pattern, lesson_id + ), f"Lesson ID '{lesson_id}' doesn't match schema pattern {hex_uuid_pattern}"