Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| from course_slide_factory.constants import STAGE_IDS | |
| from course_slide_factory.fixtures import ( | |
| invalid_layout_job, | |
| invalidated_approval_job, | |
| missing_objective_mapping_job, | |
| missing_visual_asset_job, | |
| stale_downstream_job, | |
| unsupported_claim_job, | |
| valid_minimal_job, | |
| ) | |
| from course_slide_factory.models import ArtifactStatus, IssueSeverity, IssueType, LayoutSpec | |
| from course_slide_factory.quality import ( | |
| aggregate_rubric_score, | |
| approve_current_artifact, | |
| can_unlock_next_stage, | |
| check_text_density, | |
| compute_artifact_diff, | |
| compute_objective_traces, | |
| get_current_stage_artifact, | |
| get_stage_lock_reasons, | |
| grade_stage, | |
| has_valid_human_approval, | |
| run_export_preflight, | |
| upsert_issue, | |
| validate_claim_support, | |
| validate_layout_spec, | |
| ) | |
| from course_slide_factory.review import apply_proposed_change_set | |
| from course_slide_factory.workflow import ( | |
| build_empty_state, | |
| final_render_export, | |
| generate_stage, | |
| improve_with_ai, | |
| save_human_edits, | |
| update_setup_from_inputs, | |
| ) | |
| def test_weighted_rubric_score_aggregates_and_clamps(): | |
| from course_slide_factory.models import RubricDimensionScore | |
| score = aggregate_rubric_score( | |
| [ | |
| RubricDimensionScore(dimension_id="a", label="A", score=100, weight=3), | |
| RubricDimensionScore(dimension_id="b", label="B", score=50, weight=1), | |
| ] | |
| ) | |
| assert score == 88 | |
| def test_gating_requires_score_no_blockers_approval_and_current_artifact(): | |
| state = valid_minimal_job() | |
| stage_id = "setup_inputs" | |
| assert can_unlock_next_stage(stage_id, state) | |
| state.stages[stage_id].score = 79 | |
| assert not can_unlock_next_stage(stage_id, state) | |
| state = valid_minimal_job() | |
| upsert_issue( | |
| state, | |
| IssueType.TECHNICAL_INACCURACY, | |
| IssueSeverity.BLOCKER, | |
| "Blocking issue", | |
| stage_id=stage_id, | |
| ) | |
| assert not can_unlock_next_stage(stage_id, state) | |
| state = valid_minimal_job() | |
| state.approvals = [approval for approval in state.approvals if approval.stage_id != stage_id] | |
| assert not can_unlock_next_stage(stage_id, state) | |
| def test_approval_invalidates_after_human_edit_and_ai_improvement(): | |
| state = valid_minimal_job() | |
| stage_id = "text_generation" | |
| assert has_valid_human_approval(stage_id, state) | |
| save_human_edits(state, stage_id, "edited text", "Reviewer", "Needs precision", "[]") | |
| assert not has_valid_human_approval(stage_id, state) | |
| assert any(approval.approval_status == "invalidated" for approval in state.approvals) | |
| state = valid_minimal_job() | |
| upsert_issue( | |
| state, | |
| IssueType.TEXT_DENSITY_EXCEEDED, | |
| IssueSeverity.MAJOR, | |
| "Text can be tightened.", | |
| stage_id=stage_id, | |
| slide_id="slide_1", | |
| ) | |
| improve_with_ai(state, stage_id) | |
| assert has_valid_human_approval(stage_id, state) | |
| change_set_id = next(reversed(state.proposed_change_sets)) | |
| apply_proposed_change_set(change_set_id, state) | |
| assert not has_valid_human_approval(stage_id, state) | |
| def test_upstream_change_marks_downstream_stale_and_locks(): | |
| state = stale_downstream_job() | |
| for stage_id in STAGE_IDS[STAGE_IDS.index("slide_outline_order") + 1 :]: | |
| assert state.stages[stage_id].is_stale | |
| assert "Stage is stale." in get_stage_lock_reasons(stage_id, state) | |
| assert not can_unlock_next_stage("text_generation", state) | |
| def test_objective_traceability_issues(): | |
| state = valid_minimal_job() | |
| traces = compute_objective_traces(state) | |
| assert all(trace.mapped_slide_ids for trace in traces) | |
| assert not any( | |
| issue.issue_type == IssueType.OBJECTIVE_UNCOVERED | |
| for issue in state.issues.values() | |
| if not issue.resolved | |
| ) | |
| state = missing_objective_mapping_job() | |
| traces = compute_objective_traces(state) | |
| uncovered = [trace for trace in traces if trace.coverage_status == "uncovered"] | |
| assert uncovered | |
| assert any( | |
| issue.issue_type == IssueType.OBJECTIVE_UNCOVERED | |
| and issue.severity == IssueSeverity.BLOCKER | |
| for issue in state.issues.values() | |
| ) | |
| state = valid_minimal_job() | |
| state.slides["slide_1"].objective_coverage_scores = {"obj_1": 40} | |
| traces = compute_objective_traces(state) | |
| assert any(trace.coverage_status == "weak" for trace in traces) | |
| assert any(issue.issue_type == IssueType.OBJECTIVE_WEAKLY_COVERED for issue in state.issues.values()) | |
| def test_claim_support_blocks_technical_review_and_preflight(): | |
| state = valid_minimal_job() | |
| assert validate_claim_support(state) == [] | |
| state = unsupported_claim_job() | |
| issues = validate_claim_support(state) | |
| assert issues | |
| assert issues[0].severity == IssueSeverity.BLOCKER | |
| result = grade_stage("technical_review", state) | |
| assert not result.passed_threshold | |
| report = run_export_preflight(state) | |
| assert not report.can_export | |
| assert any("unsupported_claim" in issue_id for issue_id in report.blocking_issue_ids) | |
| def test_artifact_lifecycle_and_export_require_approved_current_artifacts(): | |
| state = build_empty_state( | |
| deck_title="Lifecycle", | |
| source_url="mock://source", | |
| template_url="mock://template", | |
| ) | |
| state, _message = generate_stage(state, "setup_inputs") | |
| artifact = get_current_stage_artifact("setup_inputs", state) | |
| assert artifact is not None | |
| assert artifact.status == ArtifactStatus.CANDIDATE | |
| state.stages["setup_inputs"].score = 90 | |
| approve_current_artifact(state, "setup_inputs") | |
| assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.APPROVED | |
| state = invalidated_approval_job() | |
| report = run_export_preflight(state) | |
| assert not report.can_export | |
| state = stale_downstream_job() | |
| report = run_export_preflight(state) | |
| assert not report.can_export | |
| def test_preflight_fixture_matrix(): | |
| assert run_export_preflight(valid_minimal_job()).can_export | |
| for fixture in [ | |
| missing_objective_mapping_job, | |
| unsupported_claim_job, | |
| missing_visual_asset_job, | |
| invalid_layout_job, | |
| stale_downstream_job, | |
| invalidated_approval_job, | |
| ]: | |
| report = run_export_preflight(fixture()) | |
| assert not report.can_export | |
| state = valid_minimal_job() | |
| state.production_export_requested = True | |
| assert not run_export_preflight(state).can_export | |
| state = valid_minimal_job() | |
| state.mutation_target_url = state.source_url | |
| assert not run_export_preflight(state).can_export | |
| def test_text_density_limits_and_cognitive_load(): | |
| state = valid_minimal_job() | |
| assert check_text_density("slide_1", state) == [] | |
| state = valid_minimal_job() | |
| state.slides["slide_1"].visible_text = " ".join(["word"] * 80) | |
| issues = check_text_density("slide_1", state) | |
| assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues) | |
| state = valid_minimal_job() | |
| state.slides["slide_1"].bullet_points = ["a", "b", "c", "d", "e"] | |
| issues = check_text_density("slide_1", state) | |
| assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues) | |
| state = valid_minimal_job() | |
| state.slides["slide_1"].objective_ids = ["obj_1", "obj_2", "obj_3"] | |
| issues = check_text_density("slide_1", state) | |
| assert any(issue.issue_type == IssueType.COGNITIVE_LOAD_HIGH for issue in issues) | |
| def test_layout_validation_schema_and_slots(): | |
| state = valid_minimal_job() | |
| assert validate_layout_spec(state.layout_specs["slide_1"], state) == [] | |
| state = valid_minimal_job() | |
| unknown = LayoutSpec(slide_id="slide_1", layout_id="unknown", slot_assignments={}) | |
| issues = validate_layout_spec(unknown, state) | |
| assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues) | |
| state = valid_minimal_job() | |
| missing = LayoutSpec( | |
| slide_id="slide_1", | |
| layout_id="title_bullets_visual", | |
| slot_assignments={"title": "Only title"}, | |
| ) | |
| issues = validate_layout_spec(missing, state) | |
| assert any(issue.issue_type == IssueType.LAYOUT_SLOT_VIOLATION for issue in issues) | |
| state = valid_minimal_job() | |
| raw = LayoutSpec(slide_id="slide_1", layout_id="title_body", slot_assignments={"x": 1}) | |
| issues = validate_layout_spec(raw, state) | |
| assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues) | |
| def test_final_export_marks_approved_artifacts_exported_and_blocks_failures(): | |
| state = valid_minimal_job() | |
| state, message = final_render_export(state) | |
| assert "completed" in message | |
| assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.EXPORTED | |
| state = unsupported_claim_job() | |
| state, message = final_render_export(state) | |
| assert "failed" in message | |
| assert any(event.event_type == "export_blocked_by_preflight" for event in state.audit_events) | |
| def test_artifact_diff_supports_text_and_json(): | |
| text_diff = compute_artifact_diff("alpha\n", "beta\n") | |
| json_diff = compute_artifact_diff({"b": 2, "a": 1}, {"a": 1, "b": 3}) | |
| assert "-alpha" in text_diff | |
| assert '+ "b": 3' in json_diff | |
| def test_setup_passes_with_uploaded_text_material_only(tmp_path): | |
| material_path = tmp_path / "course-notes.md" | |
| material_path.write_text("These uploaded notes describe the course objective.", encoding="utf-8") | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Uploaded Material Deck", | |
| source_url=None, | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text="obj_1: Explain uploaded source material.", | |
| material_files=[str(material_path)], | |
| ) | |
| result = grade_stage("setup_inputs", state) | |
| assert result.passed_threshold | |
| assert state.source_chunks["upload_1"].startswith("These uploaded notes") | |
| assert state.uploaded_materials[0]["parsed"] is True | |
| def test_setup_passes_with_material_url_only(): | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="URL Material Deck", | |
| source_url="https://example.com/course-notes", | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text="obj_1: Explain URL-backed material.", | |
| ) | |
| result = grade_stage("setup_inputs", state) | |
| assert result.passed_threshold | |
| assert state.source_url == "https://example.com/course-notes" | |
| assert state.source_chunks == {} | |
| def test_setup_fails_without_upload_or_material_url(): | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Missing Material Deck", | |
| source_url=None, | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text="obj_1: Explain the material.", | |
| ) | |
| result = grade_stage("setup_inputs", state) | |
| assert not result.passed_threshold | |
| def test_imported_json_draft_creates_structured_candidate_artifacts(tmp_path): | |
| draft_path = tmp_path / "draft.json" | |
| draft_path.write_text( | |
| json.dumps( | |
| { | |
| "objectives": {"obj_1": "Explain imported draft flow."}, | |
| "slides": [ | |
| { | |
| "slide_id": "slide_1", | |
| "slide_number": 1, | |
| "title": "Imported Draft Slide", | |
| "visible_text": "Imported draft text.", | |
| "bullet_points": ["Review source", "Approve candidate"], | |
| "objective_ids": ["obj_1"], | |
| "pedagogical_role": "concept", | |
| "speaker_notes": { | |
| "slide_id": "slide_1", | |
| "notes_text": "Imported notes for the instructor.", | |
| }, | |
| } | |
| ], | |
| "claims": [ | |
| { | |
| "claim_id": "claim_1", | |
| "slide_id": "slide_1", | |
| "claim_text": "Imported draft text.", | |
| "review_status": "unsupported", | |
| } | |
| ], | |
| "layout_specs": [ | |
| { | |
| "slide_id": "slide_1", | |
| "layout_id": "title_body", | |
| "approved_template_id": "default_course_template", | |
| "slot_assignments": { | |
| "title": "Imported Draft Slide", | |
| "body": "Imported draft text.", | |
| }, | |
| } | |
| ], | |
| "visual_assets": [], | |
| } | |
| ), | |
| encoding="utf-8", | |
| ) | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Imported JSON Draft", | |
| source_url="mock://source/imported", | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text=None, | |
| start_mode="import_existing_draft", | |
| draft_file=str(draft_path), | |
| ) | |
| assert state.start_mode == "import_existing_draft" | |
| assert state.slides["slide_1"].title == "Imported Draft Slide" | |
| assert state.slides["slide_1"].speaker_notes.notes_text == "Imported notes for the instructor." | |
| assert state.layout_specs["slide_1"].layout_id == "title_body" | |
| assert state.claims["claim_1"].review_status == "unsupported" | |
| assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE | |
| assert get_current_stage_artifact("text_generation", state).status == ArtifactStatus.CANDIDATE | |
| assert not has_valid_human_approval("slide_outline_order", state) | |
| def test_imported_pptx_draft_creates_slide_records(tmp_path): | |
| from pptx import Presentation | |
| draft_path = tmp_path / "draft.pptx" | |
| presentation = Presentation() | |
| title_slide = presentation.slides.add_slide(presentation.slide_layouts[1]) | |
| title_slide.shapes.title.text = "PPTX Imported Slide" | |
| title_slide.placeholders[1].text = "First bullet\nSecond bullet" | |
| presentation.save(draft_path) | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Imported PPTX Draft", | |
| source_url="mock://source/imported", | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text=None, | |
| start_mode="import_existing_draft", | |
| draft_file=str(draft_path), | |
| ) | |
| assert state.slides["slide_1"].title == "PPTX Imported Slide" | |
| assert "First bullet" in state.slides["slide_1"].visible_text | |
| assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE | |
| def test_instruction_file_path_seeds_outline_from_sections(tmp_path): | |
| instructions_path = tmp_path / "instructions.txt" | |
| instructions_path.write_text( | |
| "\n".join( | |
| [ | |
| "Testing logistic regression", | |
| "- Purpose of video", | |
| " - Explain why testing matters after training.", | |
| "- Prerequisites for testing", | |
| " - Validation inputs, labels, and learned weights.", | |
| "- Testing process", | |
| " - Compute probabilities and convert predictions.", | |
| "- Key takeaways", | |
| ] | |
| ), | |
| encoding="utf-8", | |
| ) | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Instruction File Deck", | |
| source_url=None, | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text=None, | |
| material_files=[str(instructions_path)], | |
| start_mode="instructions_file", | |
| ) | |
| state, _message = generate_stage(state, "slide_outline_order") | |
| titles = [slide.title for slide in state.slides.values()] | |
| assert state.start_mode == "instructions_file" | |
| assert state.source_chunks["upload_1"].startswith("Testing logistic regression") | |
| assert "Purpose of video" in titles | |
| assert "Testing process" in titles | |
| assert state.slides["slide_3"].bullet_points == ["Validation inputs, labels, and learned weights."] | |
| def test_input_outline_path_seeds_slides_and_context(): | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Outline Input Deck", | |
| source_url=None, | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text=None, | |
| source_text="Audience: learners who already trained a classifier.", | |
| outline_text="Opening hook\n Why testing matters\nTesting process\n Compute h and accuracy\nWrap-up", | |
| start_mode="input_outline", | |
| ) | |
| state, _message = generate_stage(state, "slide_outline_order") | |
| assert state.start_mode == "input_outline" | |
| assert state.source_chunks["pasted_context"].startswith("Audience:") | |
| assert [slide.title for slide in state.slides.values()] == ["Opening hook", "Testing process", "Wrap-up"] | |
| assert state.slides["slide_2"].requires_visual | |
| def test_imported_image_draft_creates_visual_candidate_artifact(tmp_path): | |
| draft_path = tmp_path / "draft-slide.png" | |
| draft_path.write_bytes(b"uploaded image placeholder") | |
| state = build_empty_state() | |
| update_setup_from_inputs( | |
| state, | |
| deck_title="Image Draft Deck", | |
| source_url=None, | |
| template_url="mock://template/course", | |
| output_folder_id=None, | |
| dry_run=True, | |
| objectives_text=None, | |
| start_mode="import_existing_draft", | |
| draft_file=str(draft_path), | |
| ) | |
| assert state.start_mode == "import_existing_draft" | |
| assert state.slides["slide_1"].title == "draft slide" | |
| assert state.visual_assets["asset_1"].path_or_url == str(draft_path) | |
| assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE | |