DocDoeAI / src /video-engine /physics /production /semanticVisualIntent.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
17.8 kB
/**
* RESOLVED SEMANTIC VISUAL INTENT
*
* Every teaching scene must resolve to an explicit visual intent at
* package-build time. The runtime renderer consumes the resolved intent
* rather than guessing the topic from title strings each frame.
*
* This replaces vague keyword matching with explicit, validated intents.
*/
// ---------------------------------------------------------------------------
// Canonical chapter IDs from the authoritative manifest
// ---------------------------------------------------------------------------
export const CANONICAL_CHAPTER_IDS = [
"phy-p1-c1", // Sound Waves
"phy-p1-c2", // Lenses
"phy-p1-c3", // The World of Colours and Vision
"phy-p1-c4", // Magnetic Effect of Electric Current
"phy-p2-c5", // Electric Energy: Consumption and Conservation
"phy-p2-c6", // Electromagnetic Induction in Daily Life
"phy-p2-c7", // Mechanical Advantage in Action
] as const;
export type CanonicalChapterId = (typeof CANONICAL_CHAPTER_IDS)[number];
export function isCanonicalChapterId(id: string): id is CanonicalChapterId {
return (CANONICAL_CHAPTER_IDS as readonly string[]).includes(id);
}
// ---------------------------------------------------------------------------
// Visual families per chapter — exhaustive enumeration
// ---------------------------------------------------------------------------
export type SoundVisualFamily =
| "oscillation_source"
| "longitudinal_wave"
| "transverse_wave"
| "resonance"
| "echo_reflection"
| "audibility_range"
| "seismic_tsunami"
| "sonar"
| "ultrasonic_application"
| "wave_graph"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type LensVisualFamily =
| "lens_anatomy"
| "convex_concave_comparison"
| "optical_bench"
| "ray_rule_1"
| "ray_rule_2"
| "ray_rule_3"
| "convex_beyond_2f"
| "convex_at_2f"
| "convex_between_f_2f"
| "convex_at_f"
| "convex_inside_f"
| "concave_construction"
| "sign_convention"
| "lens_formula"
| "magnification"
| "power_of_lens"
| "camera_projector"
| "magnifying_glass"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type ColourVisualFamily =
| "prism_dispersion"
| "two_prism_recombination"
| "rainbow_raindrops"
| "rgb_additive_mixing"
| "complementary_colours"
| "coloured_object_illumination"
| "colour_filters"
| "atmospheric_scattering"
| "blue_sky"
| "red_sunset"
| "eye_anatomy"
| "accommodation"
| "near_far_point"
| "myopia"
| "hypermetropia"
| "presbyopia"
| "correcting_lens"
| "lens_power_numerical"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type MagneticVisualFamily =
| "oersted_experiment"
| "straight_conductor"
| "circular_coil"
| "solenoid"
| "electromagnet"
| "electromagnet_application"
| "force_on_conductor"
| "flemings_left_hand"
| "dc_motor"
| "split_ring_commutator"
| "moving_coil_loudspeaker"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type ElectricEnergyVisualFamily =
| "heating_circuit"
| "nichrome_coil"
| "electric_iron"
| "kettle_heater"
| "watt_hour_meter"
| "appliance_star_rating"
| "tod_timeline"
| "household_bill"
| "solar_rooftop"
| "carbon_emission"
| "conservation_transport"
| "power_formula"
| "kwh_numerical"
| "household_energy_calculation"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type InductionVisualFamily =
| "magnet_stationary"
| "magnet_moving_in"
| "magnet_stopped"
| "magnet_moving_away"
| "galvanometer_deflection"
| "ac_waveform"
| "ac_generator"
| "dc_generator"
| "slip_ring"
| "split_ring"
| "generator_energy"
| "generating_station"
| "step_up_transformer"
| "step_down_transformer"
| "transmission_towers"
| "transformer_construction"
| "domestic_wiring"
| "three_pin_plug"
| "fuse_mcb"
| "elcb"
| "appliance_earthing"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type MachineVisualFamily =
| "first_order_lever"
| "second_order_lever"
| "third_order_lever"
| "all_lever_orders"
| "fixed_pulley"
| "movable_pulley"
| "first_pulley_system"
| "second_pulley_system"
| "rope_segment_counting"
| "wheel_and_axle"
| "gear_pair"
| "gear_speed_torque"
| "inclined_plane"
| "wedge"
| "screw_wrapped_plane"
| "screw_jack"
| "no_gain_in_work"
| "real_world_applications"
| "formula_workspace"
| "numerical_workspace"
| "exam_answer"
| "recap"
| "homework"
| "context_image";
export type VisualFamily =
| SoundVisualFamily
| LensVisualFamily
| ColourVisualFamily
| MagneticVisualFamily
| ElectricEnergyVisualFamily
| InductionVisualFamily
| MachineVisualFamily;
// ---------------------------------------------------------------------------
// Forbidden visual families per chapter — prevent cross-topic misrouting
// ---------------------------------------------------------------------------
export const FORBIDDEN_FAMILIES: Record<CanonicalChapterId, string[]> = {
"phy-p1-c1": ["prism_dispersion", "lens_anatomy", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"],
"phy-p1-c2": ["prism_dispersion", "longitudinal_wave", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"],
"phy-p1-c3": ["longitudinal_wave", "oersted_experiment", "heating_circuit", "ac_generator", "first_order_lever"],
"phy-p1-c4": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "heating_circuit", "ac_generator", "first_order_lever"],
"phy-p2-c5": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "ac_generator", "first_order_lever"],
"phy-p2-c6": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "first_order_lever"],
"phy-p2-c7": ["longitudinal_wave", "prism_dispersion", "lens_anatomy", "oersted_experiment", "heating_circuit", "ac_generator"],
};
// ---------------------------------------------------------------------------
// Resolved Visual Intent — the output of the build-time router
// ---------------------------------------------------------------------------
export interface ResolvedVisualIntent {
chapterId: CanonicalChapterId;
sceneId: string;
topicKey: string;
visualFamily: VisualFamily;
/** ID from the image-asset manifest, if a generated base image is used. */
baseAssetId?: string;
/** State variant key for the same apparatus in different states. */
stateVariant?: string;
/** Deterministic overlay IDs to render on top of the base image. */
overlayIds: string[];
/** Visual families that this scene must NOT use. */
forbiddenVisualFamilies: string[];
/** Textbook source page for scientific diagrams. */
sourcePage?: number;
/** Content hash for cache invalidation. */
contentHash?: string;
}
// ---------------------------------------------------------------------------
// Topic → VisualFamily routing rules (build-time)
// ---------------------------------------------------------------------------
interface RoutingRule {
pattern: RegExp;
family: VisualFamily;
overlays?: string[];
}
const SOUND_RULES: RoutingRule[] = [
{ pattern: /resonance|forced vibration|natural frequency/i, family: "resonance" },
{ pattern: /longitudinal|compression|rarefaction/i, family: "longitudinal_wave" },
{ pattern: /transverse/i, family: "transverse_wave" },
{ pattern: /echo|reflection|reverberation/i, family: "echo_reflection" },
{ pattern: /audib|ultrasonic|infrasonic|frequency range/i, family: "audibility_range" },
{ pattern: /seismic|tsunami/i, family: "seismic_tsunami" },
{ pattern: /sonar|depth.*sea|submarine/i, family: "sonar" },
{ pattern: /ultrasound.*clean|ultrasonic.*appli|medical.*ultra/i, family: "ultrasonic_application" },
{ pattern: /displacement.*graph|wave.*graph/i, family: "wave_graph" },
{ pattern: /oscillat|vibrat|swing|pendulum|tuning fork/i, family: "oscillation_source" },
];
const LENS_RULES: RoutingRule[] = [
{ pattern: /rule 1|parallel ray/i, family: "ray_rule_1" },
{ pattern: /rule 2|optic centre|optical centre/i, family: "ray_rule_2" },
{ pattern: /rule 3|focus ray/i, family: "ray_rule_3" },
{ pattern: /beyond 2f|outside 2f/i, family: "convex_beyond_2f" },
{ pattern: /at 2f/i, family: "convex_at_2f" },
{ pattern: /between f and 2f/i, family: "convex_between_f_2f" },
{ pattern: /at f$|at the focus/i, family: "convex_at_f" },
{ pattern: /inside focus|between f and o|magnifying/i, family: "convex_inside_f" },
{ pattern: /concave.*construct|image.*concave/i, family: "concave_construction" },
{ pattern: /sign convention/i, family: "sign_convention" },
{ pattern: /lens formula|1\/f/i, family: "lens_formula" },
{ pattern: /magnification|m\s*=\s*v\/u/i, family: "magnification" },
{ pattern: /power.*lens|dioptre/i, family: "power_of_lens" },
{ pattern: /camera|projector/i, family: "camera_projector" },
{ pattern: /convex.*concave|types.*lens|shape.*lens/i, family: "convex_concave_comparison" },
{ pattern: /optical bench|screen.*convex/i, family: "optical_bench" },
{ pattern: /anatomy|parts.*lens/i, family: "lens_anatomy" },
];
const COLOUR_RULES: RoutingRule[] = [
{ pattern: /two.prism|recombination|newton.*experiment/i, family: "two_prism_recombination" },
{ pattern: /prism|dispersion|spectrum/i, family: "prism_dispersion" },
{ pattern: /rainbow/i, family: "rainbow_raindrops" },
{ pattern: /primary.*colour|secondary.*colour|additive.*mix/i, family: "rgb_additive_mixing" },
{ pattern: /complementary/i, family: "complementary_colours" },
{ pattern: /coloured.*object|illuminat.*coloured/i, family: "coloured_object_illumination" },
{ pattern: /filter/i, family: "colour_filters" },
{ pattern: /scatter|blue.*sky|rayleigh/i, family: "atmospheric_scattering" },
{ pattern: /sunset|reddening|red.*sun/i, family: "red_sunset" },
{ pattern: /myopia|short.sight|near.sight/i, family: "myopia" },
{ pattern: /hypermetropia|far.sight|long.sight/i, family: "hypermetropia" },
{ pattern: /presbyopia/i, family: "presbyopia" },
{ pattern: /correction|corrective|correcting.*lens/i, family: "correcting_lens" },
{ pattern: /eye.*anatomy|retina|cornea|ciliary/i, family: "eye_anatomy" },
{ pattern: /accommod/i, family: "accommodation" },
{ pattern: /near point|far point/i, family: "near_far_point" },
{ pattern: /power.*numerical|power.*dioptre.*calc/i, family: "lens_power_numerical" },
];
const MAGNETIC_RULES: RoutingRule[] = [
{ pattern: /oersted/i, family: "oersted_experiment" },
{ pattern: /solenoid/i, family: "solenoid" },
{ pattern: /circular.*coil|circular.*loop/i, family: "circular_coil" },
{ pattern: /straight.*conductor/i, family: "straight_conductor" },
{ pattern: /loudspeaker|speaker/i, family: "moving_coil_loudspeaker" },
{ pattern: /commutator|split.ring.*close/i, family: "split_ring_commutator" },
{ pattern: /motor|force on.*conductor|left.hand/i, family: "dc_motor" },
{ pattern: /fleming/i, family: "flemings_left_hand" },
{ pattern: /electromagnet.*crane|scrap|electromagnet.*appli/i, family: "electromagnet_application" },
{ pattern: /electromagnet/i, family: "electromagnet" },
];
const ENERGY_RULES: RoutingRule[] = [
{ pattern: /nichrome|heating coil/i, family: "nichrome_coil" },
{ pattern: /iron.*cutaway|electric iron/i, family: "electric_iron" },
{ pattern: /kettle|heater/i, family: "kettle_heater" },
{ pattern: /watt.*hour|energy meter/i, family: "watt_hour_meter" },
{ pattern: /star.*rat/i, family: "appliance_star_rating" },
{ pattern: /tod|time of day|peak.*hour/i, family: "tod_timeline" },
{ pattern: /bill|tariff/i, family: "household_bill" },
{ pattern: /solar/i, family: "solar_rooftop" },
{ pattern: /carbon|emission|footprint/i, family: "carbon_emission" },
{ pattern: /conservation|transport|led/i, family: "conservation_transport" },
{ pattern: /kwh.*numerical|household.*calc/i, family: "household_energy_calculation" },
{ pattern: /heat|joule|resistance/i, family: "heating_circuit" },
{ pattern: /power.*formula|P\s*=\s*VI/i, family: "power_formula" },
];
const INDUCTION_RULES: RoutingRule[] = [
{ pattern: /ac generator/i, family: "ac_generator" },
{ pattern: /dc generator/i, family: "dc_generator" },
{ pattern: /slip.ring/i, family: "slip_ring" },
{ pattern: /step.up/i, family: "step_up_transformer" },
{ pattern: /step.down/i, family: "step_down_transformer" },
{ pattern: /transformer.*construct|iron core.*primary/i, family: "transformer_construction" },
{ pattern: /transmission|power.*grid|distribut/i, family: "transmission_towers" },
{ pattern: /generating station|power plant/i, family: "generating_station" },
{ pattern: /domestic.*wir|house.*wir|live.*neutral/i, family: "domestic_wiring" },
{ pattern: /three.pin|plug/i, family: "three_pin_plug" },
{ pattern: /fuse|mcb/i, family: "fuse_mcb" },
{ pattern: /elcb/i, family: "elcb" },
{ pattern: /earth|ground.*fault/i, family: "appliance_earthing" },
{ pattern: /ac wave|alternating.*current.*graph/i, family: "ac_waveform" },
{ pattern: /galvanometer.*deflect/i, family: "galvanometer_deflection" },
{ pattern: /magnet.*moving.*in|moving.*towards/i, family: "magnet_moving_in" },
{ pattern: /magnet.*moving.*away|moving.*out/i, family: "magnet_moving_away" },
{ pattern: /magnet.*stop|no.*relative.*motion/i, family: "magnet_stopped" },
{ pattern: /magnet.*station|no.*change|stationary/i, family: "magnet_stationary" },
{ pattern: /generator.*energy|mechanical.*electrical/i, family: "generator_energy" },
];
const MACHINE_RULES: RoutingRule[] = [
{ pattern: /three.*order|classif.*lever|all.*lever/i, family: "all_lever_orders" },
{ pattern: /first.*order/i, family: "first_order_lever" },
{ pattern: /second.*order/i, family: "second_order_lever" },
{ pattern: /third.*order/i, family: "third_order_lever" },
{ pattern: /block.*tackle|second.*pulley.*system|compound.*pulley/i, family: "second_pulley_system" },
{ pattern: /first.*pulley.*system/i, family: "first_pulley_system" },
{ pattern: /movable.*pulley/i, family: "movable_pulley" },
{ pattern: /fixed.*pulley/i, family: "fixed_pulley" },
{ pattern: /rope.*segment|count.*rope/i, family: "rope_segment_counting" },
{ pattern: /wheel.*axle|bicycle.*axle/i, family: "wheel_and_axle" },
{ pattern: /gear.*speed|gear.*torque|gear.*trade/i, family: "gear_speed_torque" },
{ pattern: /gear/i, family: "gear_pair" },
{ pattern: /inclined.*plane|ramp|loading/i, family: "inclined_plane" },
{ pattern: /wedge/i, family: "wedge" },
{ pattern: /screw.*jack/i, family: "screw_jack" },
{ pattern: /screw|wrapped.*plane/i, family: "screw_wrapped_plane" },
{ pattern: /no.*gain.*work/i, family: "no_gain_in_work" },
];
const CHAPTER_RULES: Record<CanonicalChapterId, RoutingRule[]> = {
"phy-p1-c1": SOUND_RULES,
"phy-p1-c2": LENS_RULES,
"phy-p1-c3": COLOUR_RULES,
"phy-p1-c4": MAGNETIC_RULES,
"phy-p2-c5": ENERGY_RULES,
"phy-p2-c6": INDUCTION_RULES,
"phy-p2-c7": MACHINE_RULES,
};
// ---------------------------------------------------------------------------
// Build-time router
// ---------------------------------------------------------------------------
/** Shared segment types that don't use chapter-specific visuals. */
function sharedSegmentFamily(segmentKind: string): VisualFamily | undefined {
switch (segmentKind) {
case "board_answer": return "exam_answer";
case "practice_sample": return "numerical_workspace";
case "recap_revision": return "recap";
case "homework": return "homework";
default: return undefined;
}
}
/**
* Resolve a scene to its visual family at package-build time.
* The scene text is the concatenation of title + onScreenText + recapNote + formula.
*/
export function resolveVisualFamily(
chapterId: string,
segmentKind: string,
sceneText: string,
hasFormula: boolean,
): VisualFamily {
// 1. Shared segment types
const shared = sharedSegmentFamily(segmentKind);
if (shared) return shared;
// 2. Formula scenes
if (hasFormula && segmentKind === "visual_board") return "formula_workspace";
// 3. Chapter-specific rules
if (isCanonicalChapterId(chapterId)) {
const rules = CHAPTER_RULES[chapterId];
for (const rule of rules) {
if (rule.pattern.test(sceneText)) return rule.family;
}
}
// 4. Fallback to context_image (will be resolved by context asset routing)
return "context_image";
}
/**
* Validate that a visual family is not forbidden for the chapter.
*/
export function validateVisualIntent(intent: ResolvedVisualIntent): string[] {
const issues: string[] = [];
if (!isCanonicalChapterId(intent.chapterId)) {
issues.push(`Unknown chapterId: ${intent.chapterId}`);
return issues;
}
const forbidden = FORBIDDEN_FAMILIES[intent.chapterId];
if (forbidden.includes(intent.visualFamily)) {
issues.push(
`Scene ${intent.sceneId}: visual family "${intent.visualFamily}" is forbidden for chapter ${intent.chapterId}`
);
}
// Cross-chapter asset leak
if (intent.baseAssetId && !intent.baseAssetId.startsWith(intent.chapterId.replace(/-/g, ""))) {
// Allow shared assets
if (!intent.baseAssetId.startsWith("shared-")) {
issues.push(
`Scene ${intent.sceneId}: asset "${intent.baseAssetId}" may belong to another chapter`
);
}
}
return issues;
}