From c9504ac6c28e5271bbc776272cae497f842a7295 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:42:46 +0700 Subject: [PATCH 01/16] test: cover state-evidence production cutover --- apps/api/tests/test_api_routes.py | 100 +++++-- apps/api/tests/test_codex_bridge.py | 64 ++--- .../tests/test_opt_in_raster_courseware.py | 11 + apps/api/tests/test_phase2b_milestone.py | 5 +- apps/api/tests/test_pipeline.py | 14 +- apps/api/tests/test_presentation_shadow.py | 2 +- apps/api/tests/test_state_evidence_kernel.py | 21 +- .../test_state_evidence_production_cutover.py | 248 ++++++++++++++++++ 8 files changed, 392 insertions(+), 73 deletions(-) create mode 100644 apps/api/tests/test_state_evidence_production_cutover.py diff --git a/apps/api/tests/test_api_routes.py b/apps/api/tests/test_api_routes.py index 2122a7c..1942283 100644 --- a/apps/api/tests/test_api_routes.py +++ b/apps/api/tests/test_api_routes.py @@ -17,6 +17,20 @@ from hcs_api.strategist import build_interaction_plan, build_media_plan +def _seed_canonical_project(client: TestClient, project_id: str, title: str = "媒体") -> Path: + """Create the real State-Evidence → canonical → adapter contract for route tests.""" + storage.write_model( + project_id, + "source_material.json", + SourceMaterial(source_type="pdf", original_filename=f"{project_id}.pdf"), + ) + storage.write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title=title)) + storage.set_profile_state(project_id, "confirmed") + response = client.post(f"/api/projects/{project_id}/blueprint") + assert response.status_code == 200, response.text + return storage.ensure_project(project_id) + + def test_root_renders_chinese_console() -> None: client = TestClient(app) response = client.get("/") @@ -355,7 +369,7 @@ def test_quality_stage_does_not_advertise_render_without_lesson_artifact(tmp_pat quality = next(stage for stage in body["stages"] if stage["stage_id"] == "quality") assert quality["state"] == "not_started" assert quality["available_actions"] == [] - assert "Blueprint artifact is missing" in quality["blockers"] + assert "Legacy compatibility blueprint artifact is missing" in quality["blockers"] def test_project_stage_actions_expose_pipeline_and_handoff_operations(tmp_path, monkeypatch) -> None: @@ -403,6 +417,15 @@ def test_gate_summary_requires_all_four_gates_and_render_before_export(tmp_path, "presentation/binding_quality_report.json", ): storage.write_json(project_id, relative, {"state": "pass"}) + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) after = TestClient(app).get(f"/api/projects/{project_id}") assert after.status_code == 200 @@ -451,6 +474,15 @@ def test_force_export_cannot_bypass_missing_blueprint_or_render(tmp_path, monkey storage.write_json(project_id, "quality/evidence_alignment_report.json", {"state": "pass"}) storage.write_json(project_id, "quality/presentation_readiness_report.json", {"state": "pass"}) storage.write_json(project_id, "presentation/binding_quality_report.json", {"state": "pass"}) + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) storage.write_model(project_id, "quality_report.json", QualityReport(state="pass")) response = TestClient(app).post(f"/api/projects/{project_id}/export?force=true") @@ -558,6 +590,15 @@ def test_export_blocker_is_structured_and_carries_gate_summary(tmp_path, monkeyp storage.write_json(project_id, "quality/evidence_alignment_report.json", {"state": "pass"}) storage.write_json(project_id, "quality/presentation_readiness_report.json", {"state": "pass"}) storage.write_json(project_id, "presentation/binding_quality_report.json", {"state": "pass"}) + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) storage.write_model(project_id, "quality_report.json", QualityReport(state="blocked", blocking=["missing evidence"])) response = TestClient(app).get(f"/api/projects/{project_id}/export") @@ -653,7 +694,7 @@ def test_profile_change_invalidates_all_downstream_versions(tmp_path, monkeypatc assert response.status_code == 200 body = response.json() assert body["profile_state"] == "confirmed" - assert set(body["stale_state"]["stale_stages"]) == {"design", "presentation", "media", "render", "quality", "delivery"} + assert set(body["stale_state"]["stale_stages"]) == {"learning", "design", "presentation", "media", "render", "quality", "delivery"} assert body["preview_url"] is None assert body["export_url"] is None assert body["gate_summary"]["export_allowed"] is False @@ -691,7 +732,7 @@ def test_persisted_stale_profile_blocks_old_preview_and_export(tmp_path, monkeyp assert body["profile_state"] == "stale" assert body["stale_state"]["stale"] is True - assert set(body["stale_state"]["stale_stages"]) == {"profile", "design", "presentation", "media", "render", "quality", "delivery"} + assert set(body["stale_state"]["stale_stages"]) == {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"} assert body["gate_summary"]["stale"] is True assert body["gate_summary"]["export_allowed"] is False assert body["preview_url"] is None @@ -750,7 +791,8 @@ def test_media_route_passes_force_regenerate_to_executor(tmp_path, monkeypatch) monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime_dir / "config" / "provider_settings.json") project_id = "force-media" storage.ensure_project(project_id) - storage.write_model(project_id, "lesson_blueprint.json", LessonBlueprint(lesson_title="媒体", slides=[])) + client = TestClient(app) + _seed_canonical_project(client, project_id) captured: dict[str, bool] = {} def fake_generate(*_args, **kwargs): @@ -758,7 +800,7 @@ def fake_generate(*_args, **kwargs): return AssetManifest() monkeypatch.setattr(main, "generate_project_media", fake_generate) - response = TestClient(app).post(f"/api/projects/{project_id}/media?force_regenerate=true") + response = client.post(f"/api/projects/{project_id}/media?force_regenerate=true") assert response.status_code == 200 assert captured["force_regenerate"] is True @@ -772,6 +814,7 @@ def test_media_review_api_persists_candidate_decision_and_stales_outputs(tmp_pat monkeypatch.setattr(main, "PROJECTS_DIR", projects_dir) project_id = "media-review-api" root = storage.ensure_project(project_id) + _seed_canonical_project(TestClient(app), project_id) candidate_path = root / "assets" / "images" / "hero.png" candidate_path.write_bytes(b"not-a-real-image") candidate = AssetCandidate(id="generated-1", path="assets/images/hero.png", mime_type="image/png", content_hash="hash", source="generated") @@ -809,10 +852,11 @@ def test_unsupported_media_provider_returns_capability_blocker(tmp_path, monkeyp monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime_dir / "config" / "provider_settings.json") project_id = "unsupported-media" storage.ensure_project(project_id) - storage.write_model(project_id, "lesson_blueprint.json", LessonBlueprint(lesson_title="媒体", slides=[])) + client = TestClient(app) + _seed_canonical_project(client, project_id) storage.write_provider_settings(ProviderSettings(image=ImageProviderSettings(provider="made_up_provider"))) - response = TestClient(app).post(f"/api/projects/{project_id}/media") + response = client.post(f"/api/projects/{project_id}/media") assert response.status_code == 409 detail = response.json()["detail"] @@ -878,13 +922,14 @@ def test_provider_execution_failure_does_not_return_success(tmp_path, monkeypatc monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime_dir / "config" / "provider_settings.json") project_id = "provider-execution-failure" storage.ensure_project(project_id) - storage.write_model(project_id, "lesson_blueprint.json", LessonBlueprint(lesson_title="媒体", slides=[])) + client = TestClient(app) + _seed_canonical_project(client, project_id) storage.write_provider_settings( ProviderSettings(image=ImageProviderSettings(provider="openai_images", api_key="configured", model="image")), ) monkeypatch.setattr(main, "generate_project_media", lambda *_args, **_kwargs: (_ for _ in ()).throw(ProviderError("remote unavailable"))) - response = TestClient(app).post(f"/api/projects/{project_id}/media") + response = client.post(f"/api/projects/{project_id}/media") assert response.status_code == 502 detail = response.json()["detail"] @@ -900,9 +945,10 @@ def test_dependency_invalidation_matrix_matches_pipeline_contract(tmp_path, monk project_id = "invalidation-matrix" storage.ensure_project(project_id) expected = { - "ocr": {"profile", "design", "presentation", "media", "render", "quality", "delivery"}, - "profile": {"design", "presentation", "media", "render", "quality", "delivery"}, + "ocr": {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"}, + "profile": {"learning", "design", "presentation", "media", "render", "quality", "delivery"}, "design": {"presentation", "media", "render", "quality", "delivery"}, + "learning": {"presentation", "media", "render", "quality", "delivery"}, "blueprint": {"media", "render", "quality", "delivery"}, "media": {"render", "quality", "delivery"}, "render": {"quality", "delivery"}, @@ -1151,6 +1197,15 @@ def test_editable_pptx_export_respects_blocked_quality_and_force(tmp_path, monke storage.write_json(project_id, "quality/evidence_alignment_report.json", {"state": "pass"}) storage.write_json(project_id, "quality/presentation_readiness_report.json", {"state": "pass"}) storage.write_json(project_id, "presentation/binding_quality_report.json", {"state": "pass"}) + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) storage.write_model(project_id, "quality_report.json", QualityReport(state="blocked", blocking=["missing answer"])) client = TestClient(app) @@ -1251,18 +1306,18 @@ def test_agent_handoff_e2e_validates_then_render_exports(tmp_path, monkeypatch) assert storage.latest_export_path(project_id) is None render_response = client.post(f"/api/projects/{project_id}/render") - assert render_response.status_code == 200 - assert (project_root / "courseware" / "lesson.html").exists() - assert (project_root / "quality" / "quality_report.json").exists() - # Agent handoff/render does not fabricate the missing State-first gate - # reports; export remains unavailable until the complete gate contract is - # run. + assert render_response.status_code == 409 + assert render_response.json()["detail"]["code"] == "upstream_stale" + assert not (project_root / "courseware" / "lesson.html").exists() + assert not (project_root / "quality" / "quality_report.json").exists() + # A hand-edited compatibility Blueprint is stale by fingerprint. The + # renderer must not consume it; regenerate from canonical State-Evidence. assert storage.latest_export_path(project_id) is None export_response = client.get(f"/api/projects/{project_id}/export") assert export_response.status_code == 409 export_detail = export_response.json()["detail"] - assert export_detail["code"] == "export_gate_blocked" + assert export_detail["code"] == "export_technical_blocked" assert export_detail["gate_summary"]["overall_state"] == "stale" @@ -1279,6 +1334,15 @@ def test_blocked_quality_prevents_normal_export_but_force_export_succeeds(tmp_pa storage.write_json(project_id, "quality/evidence_alignment_report.json", {"state": "pass"}) storage.write_json(project_id, "quality/presentation_readiness_report.json", {"state": "pass"}) storage.write_json(project_id, "presentation/binding_quality_report.json", {"state": "pass"}) + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) storage.write_model( project_id, "quality_report.json", diff --git a/apps/api/tests/test_codex_bridge.py b/apps/api/tests/test_codex_bridge.py index 3155364..da947dc 100644 --- a/apps/api/tests/test_codex_bridge.py +++ b/apps/api/tests/test_codex_bridge.py @@ -9,6 +9,7 @@ import hcs_api.main as main import hcs_api.storage as storage +from hcs_api.blueprint_compatibility import _fingerprint from hcs_api.main import app from hcs_api.models import LessonBlueprint, LessonProfile, LessonSlide, MediaRequirements, SourceMaterial @@ -70,6 +71,9 @@ def test_catalog_requires_configuration_and_live_heartbeat(tmp_path: Path, monke before = client.get("/api/settings/providers/capabilities").json() llm = next(item for item in before if item["provider_id"] == "codex_chatgpt") image = next(item for item in before if item["provider_id"] == "codex_image") + assert llm["production_ready"] is False + assert "not migrated" in llm["production_unavailable_reason"] + assert "blueprint" not in llm["supported_operations"] assert llm["configured"] is True and llm["available"] is False assert image["configured"] is True and image["available"] is False assert "heartbeat" in llm["unavailable_reason"].lower() @@ -98,33 +102,11 @@ def test_blueprint_job_is_schema_validated_and_consumed_on_retry(tmp_path: Path, requested = client.post(f"/api/projects/{project_id}/blueprint") assert requested.status_code == 409 - assert requested.json()["detail"]["code"] == "codex_agent_action_required" + detail = requested.json()["detail"] + assert detail["code"] == "llm_production_contract_unsupported" + assert "State-Evidence production" in detail["message"] jobs = client.get("/api/providers/codex-bridge/jobs?state=pending", headers=AUTH) - assert jobs.status_code == 200 and len(jobs.json()) == 1 - job = jobs.json()[0] - assert job["capability"] == "llm" and job["operation"] == "blueprint" - assert TOKEN not in json.dumps(job) - - invalid = client.post( - f"/api/providers/codex-bridge/jobs/{job['job_id']}/complete-blueprint", - headers=AUTH, - json={"lesson_title": "invalid", "slides": "not-a-list"}, - ) - assert invalid.status_code == 400 - - blueprint = LessonBlueprint( - lesson_title="第一课:你好!", - slides=[LessonSlide(id=1, slide_type="CoverSlide", layout_variant="hero", title="你好")], - ) - completed = client.post( - f"/api/providers/codex-bridge/jobs/{job['job_id']}/complete-blueprint", - headers=AUTH, - json=blueprint.model_dump(mode="json"), - ) - assert completed.status_code == 200 - generated = client.post(f"/api/projects/{project_id}/blueprint") - assert generated.status_code == 200 - assert generated.json()["lesson_blueprint"]["lesson_title"] == "第一课:你好!" + assert jobs.status_code == 200 and jobs.json() == [] def test_image_job_persists_reviewable_generated_candidate(tmp_path: Path, monkeypatch) -> None: @@ -133,20 +115,24 @@ def test_image_job_persists_reviewable_generated_candidate(tmp_path: Path, monke _heartbeat(client, "image") project_id = "codeximage" storage.ensure_project(project_id) - storage.write_model(project_id, "lesson_blueprint.json", LessonBlueprint( - lesson_title="第一课:你好!", - slides=[LessonSlide( - id=1, - slide_type="CoverSlide", - layout_variant="hero", - title="你好", - media_requirements=MediaRequirements( - image_key="greeting-scene", - image_prompt="Two adult learners greeting in a bright classroom", - media_kind="raster", - ), - )], + storage.write_model(project_id, "source_material.json", SourceMaterial( + original_filename="lesson.pdf", source_type="pdf", title="你好", )) + storage.write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title="你好")) + storage.set_profile_state(project_id, "confirmed") + blueprint_response = client.post(f"/api/projects/{project_id}/blueprint") + assert blueprint_response.status_code == 200, blueprint_response.text + blueprint = storage.read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + assert blueprint is not None and blueprint.slides + blueprint.slides[0].media_requirements = MediaRequirements( + image_key="greeting-scene", + image_prompt="Two adult learners greeting in a bright classroom", + media_kind="raster", + ) + storage.write_model(project_id, "lesson_blueprint.json", blueprint) + provenance = storage.read_json(project_id, "presentation/legacy_blueprint_provenance.json") + provenance["legacy_blueprint_fingerprint"] = _fingerprint(blueprint) + storage.write_json(project_id, "presentation/legacy_blueprint_provenance.json", provenance) requested = client.post(f"/api/projects/{project_id}/media") assert requested.status_code == 409 diff --git a/apps/api/tests/test_opt_in_raster_courseware.py b/apps/api/tests/test_opt_in_raster_courseware.py index b1e2dc4..523cd59 100644 --- a/apps/api/tests/test_opt_in_raster_courseware.py +++ b/apps/api/tests/test_opt_in_raster_courseware.py @@ -98,6 +98,17 @@ def test_opt_in_raster_survives_html_pptx_and_zip(tmp_path: Path, monkeypatch) - "presentation/binding_quality_report.json", ): storage.write_json(project_id, relative, {"state": "pass"}) + # Standalone raster transport is exercised with a hand-authored renderer + # fixture; mark the required production compatibility boundary explicitly. + for relative in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) html_path = render_lesson(root, profile, blueprint, manifest, QualityReport(state="pass")) html = html_path.read_text(encoding="utf-8") assert '../assets/images/greeting_scene.png' in html diff --git a/apps/api/tests/test_phase2b_milestone.py b/apps/api/tests/test_phase2b_milestone.py index 3466d75..b081bd7 100644 --- a/apps/api/tests/test_phase2b_milestone.py +++ b/apps/api/tests/test_phase2b_milestone.py @@ -184,7 +184,10 @@ def _run_fixture(tmp_path: Path, monkeypatch, mode: str, suffix: str = "") -> _F ) content_report = evaluate_presentation_content_plan(content) canonical = attach_content_references(canonical, content) - adapted = adapt_canonical_presentation_blueprint(canonical, content) + # This fixture exercises diagnostic parity/trace reports. Production + # adapters deliberately keep the same trace in the separate mapping + # artifact instead of embedding it in learner payloads. + adapted = adapt_canonical_presentation_blueprint(canonical, content, include_diagnostic_trace=True) shadow = shadow.model_copy(update={"compatibility_contract_valid": True}) placeholder = _FixtureRun( diff --git a/apps/api/tests/test_pipeline.py b/apps/api/tests/test_pipeline.py index 2f59df6..a5df86b 100644 --- a/apps/api/tests/test_pipeline.py +++ b/apps/api/tests/test_pipeline.py @@ -32,6 +32,7 @@ generate_lesson_blueprint, generate_project_media, render_and_check, + run_blueprint_stage, write_blueprint_artifacts, write_presentation_bindings, write_spec_artifacts, @@ -66,11 +67,13 @@ def test_pptx_to_offline_zip(tmp_path: Path, monkeypatch) -> None: project_root = ensure_project(project_id) source = parse_pptx(pptx_path, project_root, "lesson.pptx") profile = infer_profile(source) - blueprint = build_blueprint(source, profile) write_model(project_id, "source_material.json", source) write_model(project_id, "lesson_profile.json", profile) - write_spec_artifacts(project_id, source, profile) - write_blueprint_artifacts(project_id, blueprint) + run_blueprint_stage(project_id, ProviderSettings()) + blueprint = __import__("hcs_api.storage", fromlist=["read_model"]).read_model( + project_id, "lesson_blueprint.json", LessonBlueprint, + ) + assert blueprint is not None manifest = generate_placeholder_media(project_root, blueprint) write_model(project_id, "asset_manifest.json", manifest) write_json(project_id, "assets/data/attribution.json", {"schema": "hanclassstudio.attribution.v1", "items": []}) @@ -115,7 +118,9 @@ def test_pptx_to_offline_zip(tmp_path: Path, monkeypatch) -> None: assert "assets/data/quality_report.json" in names assert "assets/data/attribution.json" in names assert "quality_summary.md" in names - assert any(name.startswith("assets/images/") for name in names) + # The canonical compiler currently plans audio from evidence; source + # images remain a separate media-planning migration and are not fabricated + # by the adapter. assert any(name.startswith("assets/audio/") for name in names) assert export_manifest["forced"] is False @@ -295,6 +300,7 @@ def fake_post_json(url, payload, headers, timeout): assert project_root.exists() assert blueprint.lesson_title == "LLM 生成的中文课" + assert blueprint.artifact_role == "legacy_diagnostic" assert blueprint.slides[0].id == 1 assert blueprint.slides[0].title == "LLM 封面" diff --git a/apps/api/tests/test_presentation_shadow.py b/apps/api/tests/test_presentation_shadow.py index c192b1f..97cbf6b 100644 --- a/apps/api/tests/test_presentation_shadow.py +++ b/apps/api/tests/test_presentation_shadow.py @@ -167,7 +167,7 @@ def test_compatibility_adapter_preserves_existing_lesson_blueprint_contract() -> legacy = adapt_canonical_presentation_blueprint(blueprint) assert legacy.lesson_title == "你好" assert legacy.slides[0].id == 1 - assert legacy.model_dump(mode="json")["slides"][0]["layout_variant"] == "canonical_shadow" + assert legacy.model_dump(mode="json")["slides"][0]["layout_variant"] == "canonical_compatibility" def test_shadow_legacy_adapter_does_not_select_activities() -> None: diff --git a/apps/api/tests/test_state_evidence_kernel.py b/apps/api/tests/test_state_evidence_kernel.py index 291a9e4..9f78a67 100644 --- a/apps/api/tests/test_state_evidence_kernel.py +++ b/apps/api/tests/test_state_evidence_kernel.py @@ -128,7 +128,7 @@ def test_pptx_deck_evidence_in_speaker_notes() -> None: assert "Activity:" in notes assert any("Evidence:" in n for n in s.speaker_notes) -def test_html_lesson_data_has_non_empty_evidence_ids(tmp_path: Path) -> None: +def test_html_lesson_data_does_not_expose_internal_trace_ids(tmp_path: Path) -> None: import json from hcs_api.models import QualityReport, AssetManifest from hcs_api.renderer import render_lesson @@ -147,10 +147,10 @@ def test_html_lesson_data_has_non_empty_evidence_ids(tmp_path: Path) -> None: for s in data.get("blueprint", {}).get("slides", []): for c in s.get("components", []): comp_data = c.get("data", {}) - if comp_data.get("evidence_id", "") and comp_data.get("binding_id", "") and comp_data.get("activity_id", ""): + if any(key in comp_data for key in ("evidence_id", "binding_id", "activity_id", "_shadow_trace")): found = True - assert found, "No component has a non-empty evidence_id in lesson-data" - assert "binding_id" not in html.replace(data_json, "") + assert not found, "Internal trace IDs must stay out of learner-facing lesson-data" + assert "data-shadow-" not in html def test_v0_2_1_smoke_learning_state_plan() -> None: @@ -351,7 +351,7 @@ def test_zero_beginner_sentence_drag_binding_blocks() -> None: assert any("unsuitable" in issue for issue in report.blocking) -def test_html_lesson_data_uses_binding_not_heuristic(tmp_path: Path) -> None: +def test_html_lesson_data_uses_no_internal_binding_payload(tmp_path: Path) -> None: from hcs_api.models import AssetManifest, PresentationBinding, PresentationBindingPlan, QualityReport from hcs_api.renderer import render_lesson profile, bp, _sp, _ep, _ap, bindings = _binding_fixture() @@ -363,8 +363,9 @@ def test_html_lesson_data_uses_binding_not_heuristic(tmp_path: Path) -> None: data_json = html.split('id="lesson-data">', 1)[1].split("", 1)[0] data = json.loads(data_json) component_data = data["blueprint"]["slides"][1]["components"][0]["data"] - assert component_data["evidence_id"] == "ev_binding_only" - assert component_data["binding_id"] == binding.binding_id + assert "evidence_id" not in component_data + assert "binding_id" not in component_data + assert "activity_id" not in component_data def test_html_and_pptx_consume_same_binding_for_shared_target(tmp_path: Path) -> None: @@ -385,9 +386,9 @@ def test_html_and_pptx_consume_same_binding_for_shared_target(tmp_path: Path) -> html_binding = component["data"] deck = build_pptx_deck_plan(bp, "Chinese", profile.scaffolding_language, "zero_beginner", None, ep, ap, sp, bindings) deck_slide = next(slide for slide in deck.slides if slide.slide_id == binding.slide_id) - assert html_binding is not None - assert html_binding["binding_id"] == deck_slide.binding_id - assert html_binding["evidence_id"] == deck_slide.evidence_id + assert html_binding is None + assert deck_slide.binding_id == binding.binding_id + assert deck_slide.evidence_id == binding.evidence_id def test_cover_slide_has_no_binding_by_default() -> None: diff --git a/apps/api/tests/test_state_evidence_production_cutover.py b/apps/api/tests/test_state_evidence_production_cutover.py new file mode 100644 index 0000000..9d14d26 --- /dev/null +++ b/apps/api/tests/test_state_evidence_production_cutover.py @@ -0,0 +1,248 @@ +"""Production-cutover contracts for the State-Evidence presentation path.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +import hcs_api.pipeline as pipeline +import hcs_api.state_evidence_kernel as kernel +import hcs_api.storage as storage +from hcs_api.blueprint_compatibility import ( + _fingerprint, + adapt_canonical_presentation_blueprint, + build_legacy_blueprint_provenance, + build_legacy_component_mapping, +) +from hcs_api.evidence_alignment import check_evidence_alignment +from hcs_api.main import app +from hcs_api.models import ( + ActivityPlan, + AssetManifest, + EvidenceAlignmentReport, + EvidencePlan, + EvidenceSpec, + LearningActivity, + LearningGoal, + LearningStatePlan, + LessonProfile, + ProviderSettings, + QualityReport, + SourceMaterial, +) +from hcs_api.presentation_blueprint import compile_canonical_presentation +from hcs_api.renderer import render_lesson + + +def _configure_runtime(tmp_path: Path, monkeypatch) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime / "config" / "provider_settings.json") + + +def _seed_project(project_id: str) -> Path: + root = storage.ensure_project(project_id) + storage.write_model( + project_id, + "source_material.json", + SourceMaterial(source_type="pdf", original_filename=f"{project_id}.pdf"), + ) + storage.write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title="你好")) + storage.set_profile_state(project_id, "confirmed") + storage.bump_project_revision(project_id) + return root + + +def test_full_pipeline_uses_canonical_production_and_never_direct_source_to_slides(tmp_path, monkeypatch) -> None: + _configure_runtime(tmp_path, monkeypatch) + project_id = "production-cutover" + root = _seed_project(project_id) + + def direct_source_to_slides_forbidden(*_args, **_kwargs): + raise AssertionError("direct Source-to-Slides generator was called") + + monkeypatch.setattr(pipeline, "generate_lesson_blueprint", direct_source_to_slides_forbidden) + monkeypatch.setattr(pipeline, "build_blueprint", direct_source_to_slides_forbidden) + monkeypatch.setattr(pipeline, "build_legacy_diagnostic_blueprint", direct_source_to_slides_forbidden) + + state = pipeline.run_full_pipeline(project_id, root, ProviderSettings()) + + assert state.status == "rendered" + required = ( + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/presentation_blueprint.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "blueprints/lesson_blueprint.json", + "courseware/lesson.html", + ) + assert all((root / path).is_file() for path in required) + assert state.lesson_blueprint is not None + assert state.lesson_blueprint.artifact_role == "legacy_compatibility" + assert storage.latest_export_path(project_id) is not None + media_plan = storage.read_json(project_id, "presentation/presentation_media_request_plan.json") + assert media_plan["generation_strategy"] == "production_request_identity" + + learning_payload = storage.read_json(project_id, "learning/learning_state_plan.json") + assert isinstance(learning_payload, dict) + learning_payload["cutover_test_revision"] = "changed-upstream" + storage.write_json(project_id, "learning/learning_state_plan.json", learning_payload) + stale = storage.get_project_state(project_id).stale_state + assert {"presentation", "media", "render", "quality", "delivery"}.issubset(stale.stale_stages) + assert not pipeline.production_blueprint_stage_is_current(project_id) + + +def test_alignment_blocked_stops_legacy_media_render_and_export(tmp_path, monkeypatch) -> None: + _configure_runtime(tmp_path, monkeypatch) + project_id = "blocked-alignment-cutover" + root = _seed_project(project_id) + original_build_kernel = kernel.build_full_kernel + + def blocked_kernel(*args, **kwargs): + state_plan, evidence_plan, activity_plan, _ = original_build_kernel(*args, **kwargs) + return ( + state_plan, + evidence_plan, + activity_plan, + EvidenceAlignmentReport(state="blocked", blocking=["fixture alignment block"]), + ) + + monkeypatch.setattr(kernel, "build_full_kernel", blocked_kernel) + pipeline.run_full_pipeline(project_id, root, ProviderSettings()) + + assert storage.read_json(project_id, "quality/evidence_alignment_report.json")["state"] == "blocked" + assert storage.read_json(project_id, "quality/kernel_revision_plan.json")["state"] == "blocked" + assert not (root / "blueprints/lesson_blueprint.json").exists() + assert not (root / "assets/data/asset_manifest.json").exists() + assert not (root / "courseware/lesson.html").exists() + assert storage.latest_export_path(project_id) is None + + +def test_adapter_provenance_is_deterministic_and_student_payload_is_trace_free(tmp_path: Path) -> None: + goal = LearningGoal( + id="goal_greeting", + description="Recognize the approved greeting.", + skill_focus="recognition", + target_language=["你好"], + ) + evidence = EvidenceSpec( + id="evidence_greeting", + goal_id=goal.id, + evidence_type="deterministic_choice", + collection_method="learner_response", + target_items=["你好"], + ) + activity = LearningActivity( + id="activity_greeting", + evidence_ids=[evidence.id], + activity_type="scene_choice", + output_type="selection", + learner_action="Choose the approved greeting.", + ) + state = LearningStatePlan(lesson_title="你好", learning_goals=[goal]) + evidence_plan = EvidencePlan(evidence_specs=[evidence]) + activity_plan = ActivityPlan(activities=[activity]) + alignment = check_evidence_alignment(state, evidence_plan, activity_plan) + _, canonical, _ = compile_canonical_presentation(state, evidence_plan, activity_plan, alignment) + assert canonical is not None + + production_legacy = adapt_canonical_presentation_blueprint(canonical) + repeated_legacy = adapt_canonical_presentation_blueprint(canonical) + mapping = build_legacy_component_mapping(canonical, production_legacy) + provenance = build_legacy_blueprint_provenance(canonical, production_legacy, mapping) + + assert production_legacy.model_dump(mode="json") == repeated_legacy.model_dump(mode="json") + assert mapping.state == "pass" + trace = mapping.mappings[0] + assert trace.presentation_unit_id and trace.binding_id and trace.activity_id + assert trace.evidence_ids == [evidence.id] + assert trace.legacy_slide_id and trace.legacy_component_id + assert provenance.legacy_blueprint_fingerprint == _fingerprint(production_legacy) + assert provenance.canonical_blueprint_fingerprint == _fingerprint(canonical) + + diagnostic_legacy = adapt_canonical_presentation_blueprint(canonical, include_diagnostic_trace=True) + assert "_shadow_trace" in diagnostic_legacy.slides[0].components[0].data + html_path = render_lesson( + tmp_path, + LessonProfile(lesson_title="你好"), + production_legacy, + AssetManifest(), + QualityReport(), + render_mode="classroom", + ) + html = html_path.read_text(encoding="utf-8") + assert "_shadow_trace" not in html + assert "data-shadow-" not in html + lesson_data = json.loads(html.split('id="lesson-data">', 1)[1].split("", 1)[0]) + serialized_components = json.dumps(lesson_data["blueprint"]["slides"], ensure_ascii=False) + assert "evidence_id" not in serialized_components + assert "binding_id" not in serialized_components + + debug_html = render_lesson( + tmp_path / "debug", + LessonProfile(lesson_title="你好"), + diagnostic_legacy, + AssetManifest(), + QualityReport(), + render_mode="debug", + ).read_text(encoding="utf-8") + assert "_shadow_trace" not in debug_html + assert "data-shadow-" not in debug_html + + +def test_blueprint_api_runs_kernel_and_rejects_legacy_write_path(tmp_path, monkeypatch) -> None: + _configure_runtime(tmp_path, monkeypatch) + monkeypatch.setattr(__import__("hcs_api.main", fromlist=["PROJECTS_DIR"]), "PROJECTS_DIR", tmp_path / "runtime" / "projects") + project_id = "blueprint-api-cutover" + _seed_project(project_id) + calls = {"kernel": 0} + original_build_kernel = kernel.build_full_kernel + + def spy_kernel(*args, **kwargs): + calls["kernel"] += 1 + return original_build_kernel(*args, **kwargs) + + monkeypatch.setattr(kernel, "build_full_kernel", spy_kernel) + monkeypatch.setattr(pipeline, "generate_lesson_blueprint", lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("legacy generator called"))) + monkeypatch.setattr(pipeline, "build_legacy_diagnostic_blueprint", lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("legacy builder called"))) + client = TestClient(app) + + response = client.post(f"/api/projects/{project_id}/blueprint") + assert response.status_code == 200, response.text + assert calls["kernel"] == 1 + assert response.json()["lesson_blueprint"]["artifact_role"] == "legacy_compatibility" + assert client.put( + f"/api/projects/{project_id}/blueprint", + json={"lesson_title": "手改", "slides": []}, + ).json()["detail"]["code"] == "legacy_blueprint_read_only" + + +def test_stale_state_matrix_invalidates_canonical_and_delivery_downstream(tmp_path, monkeypatch) -> None: + _configure_runtime(tmp_path, monkeypatch) + project_id = "stale-cutover" + storage.ensure_project(project_id) + all_stages = {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"} + expected = { + "source": {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"}, + "profile": {"learning", "design", "presentation", "media", "render", "quality", "delivery"}, + "learning": {"presentation", "media", "render", "quality", "delivery"}, + "design": {"presentation", "media", "render", "quality", "delivery"}, + } + for dependency, stages in expected.items(): + storage.clear_stale_state(project_id, stages=all_stages) + storage.invalidate_downstream(project_id, dependency, f"{dependency} changed") + stale = storage.read_json(project_id, "assets/data/stale_state.json") + assert set(stale["stale_stages"]) == stages From 18829b9557ce321b6274ead4a3732b706c7c6558 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:04 +0700 Subject: [PATCH 02/16] refactor: make canonical presentation artifacts production-owned --- apps/api/src/hcs_api/models.py | 75 +- apps/api/src/hcs_api/pipeline.py | 924 +++++++++++++----- .../presentation_asset_reconciliation.py | 6 +- .../api/src/hcs_api/presentation_blueprint.py | 23 +- apps/api/src/hcs_api/presentation_content.py | 136 ++- .../hcs_api/presentation_media_requests.py | 18 +- apps/api/src/hcs_api/presentation_parity.py | 12 +- apps/api/src/hcs_api/storage.py | 107 +- apps/api/src/hcs_api/v2_cutover_readiness.py | 11 +- 9 files changed, 1035 insertions(+), 277 deletions(-) diff --git a/apps/api/src/hcs_api/models.py b/apps/api/src/hcs_api/models.py index 018f6ff..86d2530 100644 --- a/apps/api/src/hcs_api/models.py +++ b/apps/api/src/hcs_api/models.py @@ -288,12 +288,17 @@ class LessonSlide(BaseModel): class LessonBlueprint(BaseModel): + """Renderer compatibility input, never the teaching-design authority.""" + route_hint: str = "" lesson_title: str = "" objectives: list[str] = Field(default_factory=list) key_vocabulary: list[dict[str, str]] = Field(default_factory=list) grammar_points: list[str] = Field(default_factory=list) slides: list[LessonSlide] = Field(default_factory=list) + artifact_role: Literal["legacy_compatibility", "legacy_diagnostic"] = "legacy_compatibility" + canonical_source_artifact: str = "" + provenance_artifact: str = "" ThemeDecisionSource = Literal[ @@ -872,6 +877,8 @@ class ProviderCapabilityDescriptor(BaseModel): configured: bool = False available: bool = False experimental: bool = False + production_ready: bool = True + production_unavailable_reason: str | None = None unavailable_reason: str | None = None official_homepage_url: str | None = None api_signup_url: str | None = None @@ -1914,7 +1921,7 @@ class PresentationMediaRequestPlan(BaseModel): schema_: str = Field(default="hanclassstudio.presentation_media_requests.v1", alias="schema") requests: list[PresentationMediaRequest] = Field(default_factory=list) source_content_plan_path: str = "presentation/presentation_content_plan.json" - generation_strategy: str = "shadow_request_identity_only" + generation_strategy: str = "production_request_identity" deterministic: bool = True warnings: list[str] = Field(default_factory=list) trace: list[PresentationTrace] = Field(default_factory=list) @@ -1938,7 +1945,7 @@ class PresentationMediaRequestReport(BaseModel): deterministic: bool = True trace_coverage: float = 0.0 asset_manifest_trace_supported: bool = False - generation_integration_mode: str = "shadow_linkage" + generation_integration_mode: str = "legacy_adapter_media" source_artifacts_checked: list[str] = Field(default_factory=list) notes: list[str] = Field(default_factory=list) @@ -2054,7 +2061,7 @@ class PresentationMediaProjectionLinkPlan(BaseModel): class PresentationShadowReport(BaseModel): - """Status for the non-production v2 compiler path, not a pedagogical gate.""" + """Diagnostic status for the canonical presentation compiler.""" model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) @@ -2066,6 +2073,68 @@ class PresentationShadowReport(BaseModel): compatibility_contract_valid: bool = False +class LegacyComponentMapping(BaseModel): + """Non-learner-facing trace from an approved unit to a legacy target.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.legacy_component_mapping_entry.v1", alias="schema") + mapping_id: str + presentation_unit_id: str + binding_id: str + activity_id: str + evidence_ids: list[str] = Field(default_factory=list) + content_item_id: str | None = None + legacy_slide_id: int | None = None + legacy_component_id: str | None = None + structural_role: str = "learner_interaction" + learner_visible: bool = True + trace: PresentationTrace + + +class LegacyComponentMappingPlan(BaseModel): + """Deterministic adapter trace kept outside learner-facing courseware.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.legacy_component_mapping.v2", alias="schema") + state: QualityState = "pass" + mappings: list[LegacyComponentMapping] = Field(default_factory=list) + canonical_blueprint_path: str = "presentation/presentation_blueprint.json" + legacy_blueprint_path: str = "blueprints/lesson_blueprint.json" + canonical_blueprint_fingerprint: str = "" + legacy_blueprint_fingerprint: str = "" + compatibility_artifact: bool = True + deterministic: bool = True + warnings: list[str] = Field(default_factory=list) + blocking: list[str] = Field(default_factory=list) + + +class LegacyBlueprintProvenance(BaseModel): + """Provenance manifest for the legacy renderer compatibility artifact.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.legacy_blueprint_provenance.v1", alias="schema") + state: QualityState = "pass" + artifact_role: Literal["legacy_compatibility"] = "legacy_compatibility" + adapter: str = "adapt_canonical_presentation_blueprint" + canonical_blueprint_path: str = "presentation/presentation_blueprint.json" + mapping_artifact_path: str = "presentation/legacy_component_mapping.json" + legacy_blueprint_path: str = "blueprints/lesson_blueprint.json" + canonical_blueprint_fingerprint: str = "" + legacy_blueprint_fingerprint: str = "" + source_artifacts: list[str] = Field(default_factory=list) + upstream_artifact_fingerprints: dict[str, str] = Field(default_factory=dict) + presentation_unit_count: int = 0 + legacy_slide_count: int = 0 + legacy_component_count: int = 0 + learner_visible_mapping_count: int = 0 + deterministic: bool = True + warnings: list[str] = Field(default_factory=list) + blocking: list[str] = Field(default_factory=list) + + class PresentationParityReport(BaseModel): """Diagnostic-only structural comparison of v2 and production presentation inputs.""" diff --git a/apps/api/src/hcs_api/pipeline.py b/apps/api/src/hcs_api/pipeline.py index fa6d537..4731b0f 100644 --- a/apps/api/src/hcs_api/pipeline.py +++ b/apps/api/src/hcs_api/pipeline.py @@ -1,8 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path +from typing import Any -from .agents import build_blueprint +from .agents import build_blueprint, build_legacy_diagnostic_blueprint from .analysis import extract_candidates from .blueprint_utils import normalize_component_ids from .learner_comprehension import ( @@ -13,13 +15,28 @@ ) from .media import generate_configured_media from .models import ( - AssetManifest, ClassroomQualityReport, LessonBlueprint, LessonProfile, - ProjectState, ProviderSettings, QualityReport, SourceMaterial, TeachingCandidates, + ActivityPlan, + AssetManifest, + ClassroomQualityReport, + EvidenceAlignmentReport, + EvidencePlan, + LessonBlueprint, + LessonProfile, + LearningStatePlan, + PresentationBinding, + PresentationBindingPlan, + PresentationContentPlan, + PresentationMediaRequestPlan, + ProjectState, + ProviderSettings, + QualityReport, + SourceMaterial, + TeachingCandidates, ) -from .providers import ProviderError, generate_blueprint_with_llm +from .providers import ProviderError from .quality import check_classroom_quality, check_quality from .renderer import render_lesson -from .storage import get_project_state, read_json, read_model, read_provider_settings, write_json, write_model, write_text, zip_output +from .storage import artifact_fingerprint, clear_stale_state, get_project_state, read_json, read_model, read_provider_settings, write_json, write_model, write_text, zip_output from .strategist import build_interaction_plan, build_lesson_spec, build_media_plan, build_spec_lock from .syllabus_engine import ( build_allowed_text_plan, @@ -30,11 +47,23 @@ ) -SHADOW_PRESENTATION_ARTIFACTS = ( +PRODUCTION_PRESENTATION_ARTIFACTS = ( + "presentation/abstract_activity_bindings.json", "presentation/presentation_blueprint.json", "presentation/presentation_content_plan.json", "presentation/presentation_content_plan.reconciled.json", "presentation/presentation_media_request_plan.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", +) + +DIAGNOSTIC_PRESENTATION_ARTIFACTS = ( "presentation/presentation_media_asset_links.shadow.json", "presentation/presentation_media_projection_links.shadow.json", "presentation/legacy_blueprint_from_v2.shadow.json", @@ -47,7 +76,30 @@ "quality/presentation_adapter_assessment_report.json", ) -CONTENT_DOWNSTREAM_ARTIFACTS = SHADOW_PRESENTATION_ARTIFACTS[2:] +# Historical name retained for diagnostic callers. The abstract binding plan +# is intentionally excluded: even a blocked diagnostic compile records the +# kernel's attempted binding contract, while canonical/legacy outputs are +# removed. +SHADOW_PRESENTATION_ARTIFACTS = ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_content_plan.reconciled.json", + "presentation/presentation_media_request_plan.json", + *DIAGNOSTIC_PRESENTATION_ARTIFACTS, +) +CONTENT_DOWNSTREAM_ARTIFACTS = ( + "presentation/presentation_content_plan.json", + "presentation/presentation_content_plan.reconciled.json", + "presentation/presentation_media_request_plan.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_readiness_report.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", + *DIAGNOSTIC_PRESENTATION_ARTIFACTS, +) V2_INTERNAL_CUTOVER_ARTIFACTS = ( "quality/v2_cutover_readiness_report.json", "quality/v2_rendered_output_review.json", @@ -56,6 +108,64 @@ "diagnostics/v2_rendered_output", ) +UPSTREAM_PRESENTATION_ARTIFACTS = ( + "sources/source_material.json", + "assets/data/lesson_profile.json", + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/abstract_activity_bindings.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_content_plan.reconciled.json", + "presentation/presentation_media_request_plan.json", +) + +PRODUCTION_RESET_ARTIFACTS = ( + *PRODUCTION_PRESENTATION_ARTIFACTS, + "blueprints/lesson_blueprint.json", + "blueprints/interaction_plan.json", + "blueprints/media_plan.json", + "assets/data/asset_manifest.json", + "assets/data/attribution.json", + "courseware/lesson.html", + "courseware/lesson_classroom.html", + "courseware/render_manifest.json", + "quality/quality_report.json", + "quality/quality_summary.md", + "quality/classroom_quality_report.json", + "quality/comprehensibility_report.json", + "quality/off_level_report.json", + "quality/realization_report.json", + "quality/courseware_review_report.json", + "quality/presentation_revision_plan.json", + "quality/kernel_revision_plan.json", +) + + +@dataclass(frozen=True) +class _BlueprintStage: + source: SourceMaterial + profile: LessonProfile + candidates: TeachingCandidates + language_items: list[Any] + learner_model: Any + difficulty: Any + state_plan: LearningStatePlan + evidence_plan: EvidencePlan + activity_plan: ActivityPlan + alignment: EvidenceAlignmentReport + abstract_bindings: Any | None = None + canonical: Any | None = None + content_plan: PresentationContentPlan | None = None + media_request_plan: PresentationMediaRequestPlan | None = None + legacy: LessonBlueprint | None = None + mapping: Any | None = None + provenance: Any | None = None + binding_plan: PresentationBindingPlan | None = None + readiness: Any | None = None + blocked: bool = False + def _remove_project_artifacts(project_id: str, relative_paths: tuple[str, ...]) -> None: import shutil @@ -71,7 +181,77 @@ def _remove_project_artifacts(project_id: str, relative_paths: tuple[str, ...]) path.unlink() -def generate_lesson_blueprint( +def production_blueprint_stage_is_current(project_id: str) -> bool: + """Return whether the current canonical/adapter stage can be reused. + + Media, render, quality, and delivery may be stale while this stage remains + current. Any upstream or presentation staleness, blocked gate, malformed + provenance, or fingerprint mismatch requires deterministic regeneration. + """ + state = get_project_state(project_id) + if set(state.stale_state.stale_stages).intersection({"source", "ocr", "profile", "learning", "design", "presentation"}): + return False + required = ( + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/abstract_activity_bindings.json", + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + ) + if any(read_json(project_id, path) is None for path in required): + return False + if any( + isinstance(read_json(project_id, path), dict) + and read_json(project_id, path).get("state") == "blocked" + for path in ( + "quality/evidence_alignment_report.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + "quality/presentation_revision_plan.json", + ) + ): + return False + blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + canonical_payload = read_json(project_id, "presentation/presentation_blueprint.json") + provenance_payload = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + if blueprint is None or not isinstance(canonical_payload, dict) or not isinstance(provenance_payload, dict): + return False + try: + from .blueprint_compatibility import _fingerprint + from .models import CanonicalPresentationBlueprint, LegacyBlueprintProvenance + + canonical = CanonicalPresentationBlueprint.model_validate(canonical_payload) + provenance = LegacyBlueprintProvenance.model_validate(provenance_payload) + except Exception: + return False + return ( + provenance.artifact_role == "legacy_compatibility" + and bool(provenance.legacy_blueprint_fingerprint) + and bool(provenance.canonical_blueprint_fingerprint) + and bool(provenance.upstream_artifact_fingerprints) + and all( + artifact_fingerprint(project_id, path) == expected + for path, expected in provenance.upstream_artifact_fingerprints.items() + ) + and _fingerprint(blueprint) == provenance.legacy_blueprint_fingerprint + and _fingerprint(canonical) == provenance.canonical_blueprint_fingerprint + ) + + +def generate_legacy_diagnostic_blueprint( source: SourceMaterial, profile: LessonProfile, settings: ProviderSettings, @@ -79,6 +259,10 @@ def generate_lesson_blueprint( language_items: list | None = None, project_id: str | None = None, ) -> tuple[LessonBlueprint, TeachingCandidates]: + """Explicit legacy diagnostic generator retained for fixtures only. + + ``run_full_pipeline`` and the Blueprint API never call this function. + """ # Always extract teaching candidates from source candidates = candidates or extract_candidates(source) from .learner_comprehension import build_language_items, build_learner_model @@ -86,14 +270,21 @@ def generate_lesson_blueprint( learner_model = build_learner_model(profile) language_items = build_language_items(candidates, learner_model) if settings.llm.provider == "deterministic": - blueprint = build_blueprint(source, profile, candidates, language_items) + blueprint = build_legacy_diagnostic_blueprint(source, profile, candidates, language_items) else: - blueprint = generate_blueprint_with_llm(source, profile, settings.llm, project_id) + from .providers import generate_legacy_diagnostic_blueprint_with_llm + + blueprint = generate_legacy_diagnostic_blueprint_with_llm(source, profile, settings.llm, project_id) if blueprint is None: raise ProviderError("Selected LLM provider is not configured for execution") return blueprint, candidates +# Compatibility import for fixtures and migration comparisons. Production +# routes intentionally import only the canonical blueprint-stage entry point. +generate_lesson_blueprint = generate_legacy_diagnostic_blueprint + + def generate_project_media( project_root: Path, blueprint: LessonBlueprint, @@ -120,8 +311,16 @@ def write_spec_artifacts( def write_blueprint_artifacts(project_id: str, blueprint: LessonBlueprint) -> None: - # Manual legacy edits must never retain a prior v2 route decision or output. + """Write a legacy fixture/diagnostic blueprint. + + Production code must call ``write_legacy_compatibility_artifacts``. This + compatibility facade remains for agent handoff and regression fixtures and + is intentionally not used by ``run_full_pipeline``. + """ _remove_project_artifacts(project_id, V2_INTERNAL_CUTOVER_ARTIFACTS) + blueprint.artifact_role = "legacy_diagnostic" + blueprint.canonical_source_artifact = "" + blueprint.provenance_artifact = "" normalize_component_ids(blueprint) write_model(project_id, "lesson_blueprint.json", blueprint) write_json(project_id, "blueprints/interaction_plan.json", build_interaction_plan(blueprint)) @@ -135,9 +334,16 @@ def write_presentation_bindings( activity_plan, state_plan, learner_level: str, + mapping_plan=None, ): - from .presentation_bindings import build_activity_bindings - binding_plan = build_activity_bindings(blueprint, evidence_plan, activity_plan, state_plan, learner_level) + if mapping_plan is None: + from .presentation_bindings import build_activity_bindings + + binding_plan = build_activity_bindings(blueprint, evidence_plan, activity_plan, state_plan, learner_level) + else: + binding_plan = _build_bindings_from_canonical_mapping( + blueprint, evidence_plan, activity_plan, state_plan, learner_level, mapping_plan, + ) payload = binding_plan.model_dump(mode="json", by_alias=True) write_json(project_id, "presentation/activity_bindings.json", payload) write_json(project_id, "presentation/binding_quality_report.json", payload) @@ -158,6 +364,8 @@ def write_presentation_readiness( activity_plan, binding_plan, alignment_report, + *, + binding_strategy: str = "legacy_resolved", ): from .presentation_readiness import check_presentation_readiness @@ -167,6 +375,7 @@ def write_presentation_readiness( activity_plan, binding_plan, alignment_report, + binding_strategy=binding_strategy, ) write_json(project_id, "quality/presentation_readiness_report.json", report.model_dump(mode="json", by_alias=True)) return report @@ -179,31 +388,51 @@ def write_presentation_shadow_artifacts( activity_plan, alignment_report, ): - """Dual-write v2 presentation artifacts without touching the production blueprint.""" + """Backward-compatible diagnostic wrapper for canonical compilation.""" + bindings, canonical, report = write_presentation_canonical_artifacts( + project_id, state_plan, evidence_plan, activity_plan, alignment_report, + ) + if canonical is not None: + from .blueprint_compatibility import adapt_canonical_presentation_blueprint + + try: + adapt_canonical_presentation_blueprint(canonical) + report.compatibility_contract_valid = True + except Exception as exc: # pragma: no cover - defensive diagnostic isolation + report.state = "blocked" + report.blocking.append(f"Compatibility adapter rejected canonical presentation: {exc}") + report.compatibility_contract_valid = False + from .presentation_blueprint import SHADOW_REPORT_PATH + + write_json(project_id, SHADOW_REPORT_PATH, report.model_dump(mode="json", by_alias=True)) + return bindings, canonical, report + + +def write_presentation_canonical_artifacts( + project_id: str, + state_plan, + evidence_plan, + activity_plan, + alignment_report, + *, + defer_canonical: bool = False, +): + """Write the production canonical presentation stage from kernel artifacts.""" + _remove_project_artifacts(project_id, DIAGNOSTIC_PRESENTATION_ARTIFACTS) _remove_project_artifacts(project_id, SHADOW_PRESENTATION_ARTIFACTS) - from .blueprint_compatibility import adapt_canonical_presentation_blueprint from .presentation_blueprint import ( ABSTRACT_BINDING_PATH, CANONICAL_BLUEPRINT_PATH, SHADOW_REPORT_PATH, - compile_shadow_presentation, + compile_canonical_presentation, ) - bindings, canonical, report = compile_shadow_presentation( + bindings, canonical, report = compile_canonical_presentation( state_plan, evidence_plan, activity_plan, alignment_report, ) write_json(project_id, ABSTRACT_BINDING_PATH, bindings.model_dump(mode="json", by_alias=True)) - if canonical is not None: - try: - # Validate the adapter seam in memory only. The production legacy - # blueprint is neither read nor written by this shadow path. - adapt_canonical_presentation_blueprint(canonical) - report.compatibility_contract_valid = True - write_json(project_id, CANONICAL_BLUEPRINT_PATH, canonical.model_dump(mode="json", by_alias=True)) - except Exception as exc: # pragma: no cover - defensive shadow isolation - report.state = "blocked" - report.blocking.append(f"Legacy compatibility adapter rejected canonical shadow blueprint: {exc}") - report.compatibility_contract_valid = False + if canonical is not None and not defer_canonical: + write_json(project_id, CANONICAL_BLUEPRINT_PATH, canonical.model_dump(mode="json", by_alias=True)) write_json(project_id, SHADOW_REPORT_PATH, report.model_dump(mode="json", by_alias=True)) return bindings, canonical, report @@ -218,7 +447,27 @@ def write_presentation_content_shadow_artifacts( language_items, asset_manifest=None, ): - """Write v2 content artifacts and update only the shadow canonical reference graph.""" + """Backward-compatible diagnostic wrapper for content compilation.""" + return write_presentation_content_artifacts( + project_id, state_plan, evidence_plan, activity_plan, binding_plan, + canonical_blueprint, language_items, asset_manifest, allow_planned_audio=False, + ) + + +def write_presentation_content_artifacts( + project_id: str, + state_plan, + evidence_plan, + activity_plan, + binding_plan, + canonical_blueprint, + language_items, + asset_manifest=None, + *, + allow_planned_audio: bool = False, + write_canonical: bool = True, +): + """Write the production content plan and its canonical references.""" _remove_project_artifacts(project_id, CONTENT_DOWNSTREAM_ARTIFACTS) from .presentation_content import ( CONTENT_PLAN_PATH, @@ -228,15 +477,338 @@ def write_presentation_content_shadow_artifacts( ) plan, report = build_presentation_content_plan( - state_plan, evidence_plan, activity_plan, binding_plan, None, language_items, asset_manifest, + state_plan, + evidence_plan, + activity_plan, + binding_plan, + canonical_blueprint, + language_items, + asset_manifest, + allow_planned_audio=allow_planned_audio, ) enriched = attach_content_references(canonical_blueprint, plan) write_json(project_id, CONTENT_PLAN_PATH, plan.model_dump(mode="json", by_alias=True)) write_json(project_id, CONTENT_REPORT_PATH, report.model_dump(mode="json", by_alias=True)) - write_json(project_id, "presentation/presentation_blueprint.json", enriched.model_dump(mode="json", by_alias=True)) + if write_canonical: + write_json(project_id, "presentation/presentation_blueprint.json", enriched.model_dump(mode="json", by_alias=True)) return plan, report, enriched +def write_presentation_media_request_artifacts(project_id: str): + """Write the deterministic media-request plan used by production media.""" + from .presentation_media_requests import run_presentation_media_request_plan + + return run_presentation_media_request_plan(project_id) + + +def write_legacy_compatibility_artifacts( + project_id: str, + canonical_blueprint, + content_plan=None, + media_request_plan=None, + *, + allow_planned_media: bool = False, +): + """Adapt canonical presentation into the existing HTML/PPTX input shape.""" + from .blueprint_compatibility import ( + build_legacy_blueprint_provenance, + build_legacy_component_mapping, + adapt_canonical_presentation_blueprint, + ) + + legacy = adapt_canonical_presentation_blueprint( + canonical_blueprint, + content_plan, + media_request_plan, + allow_planned_media=allow_planned_media, + ) + normalize_component_ids(legacy) + mapping = build_legacy_component_mapping(canonical_blueprint, legacy, content_plan) + upstream_fingerprints = { + path: fingerprint + for path in UPSTREAM_PRESENTATION_ARTIFACTS + if (fingerprint := artifact_fingerprint(project_id, path)) is not None + } + provenance = build_legacy_blueprint_provenance( + canonical_blueprint, + legacy, + mapping, + upstream_artifact_fingerprints=upstream_fingerprints, + ) + write_json(project_id, "presentation/legacy_component_mapping.json", mapping.model_dump(mode="json", by_alias=True)) + write_json(project_id, "presentation/legacy_blueprint_provenance.json", provenance.model_dump(mode="json", by_alias=True)) + if mapping.state == "blocked": + return None, mapping, provenance + write_model(project_id, "lesson_blueprint.json", legacy) + write_json(project_id, "blueprints/interaction_plan.json", build_interaction_plan(legacy)) + write_json(project_id, "blueprints/media_plan.json", build_media_plan(legacy)) + return legacy, mapping, provenance + + +def _build_bindings_from_canonical_mapping( + blueprint, + evidence_plan, + activity_plan, + state_plan, + learner_level: str, + mapping_plan, +) -> PresentationBindingPlan: + """Resolve renderer targets only from the adapter's deterministic mapping.""" + from .presentation_bindings import check_activity_bindings + + activities = {activity.activity_id: activity for activity in activity_plan.activities} + evidence = {item.evidence_id: item for item in evidence_plan.evidence_specs} + bindings: list[PresentationBinding] = [] + for mapping in mapping_plan.mappings: + activity = activities.get(mapping.activity_id) + if activity is None: + continue + teacher_only = not mapping.learner_visible + modes = set(activity.allowed_presentation_modes or []) + if teacher_only: + modes.update({"speaker_notes", "teacher_observation"}) + else: + if "html_interactive" in modes: + modes.add("html_classroom") + if "pptx_classroom" in modes: + modes.add("speaker_notes") + modes.update({"html_interactive", "html_classroom", "pptx_classroom", "speaker_notes"}) + for evidence_id in mapping.evidence_ids: + if evidence_id not in evidence: + continue + bindings.append(PresentationBinding( + binding_id=f"{mapping.binding_id}_{evidence_id}", + activity_id=mapping.activity_id, + evidence_id=evidence_id, + slide_id=mapping.legacy_slide_id or 0, + component_id=mapping.legacy_component_id, + presentation_modes=sorted(modes), + binding_confidence=1.0, + binding_reason="canonical_presentation_adapter_mapping", + teacher_note_policy="include_evidence_claim_pass_fail", + )) + return check_activity_bindings( + blueprint, evidence_plan, activity_plan, state_plan, + PresentationBindingPlan(bindings=bindings), learner_level, + ) + + +def _write_presentation_revision_artifact(project_id: str, *, blocking: list[str], stage: str) -> None: + write_json(project_id, "quality/presentation_revision_plan.json", { + "schema": "hanclassstudio.presentation_revision_plan.v1", + "state": "blocked", + "blocked_stage": stage, + "blocking_issues": list(dict.fromkeys(str(item) for item in blocking))[:20], + "message": "Presentation production is blocked. Revise the authoritative upstream artifact and rerun; no legacy fallback is permitted.", + "authoritative_inputs": [ + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/presentation_blueprint.json", + ], + }) + + +def _prepare_design_and_kernel(project_id: str) -> tuple[SourceMaterial, LessonProfile, TeachingCandidates, list[Any], Any, Any, LearningStatePlan, EvidencePlan, ActivityPlan, EvidenceAlignmentReport]: + source = read_model(project_id, "source_material.json", SourceMaterial) + profile = read_model(project_id, "lesson_profile.json", LessonProfile) + if not source or not profile: + raise ValueError("Project needs source material and lesson profile") + + write_spec_artifacts(project_id, source, profile) + candidates = extract_candidates(source) + write_json(project_id, "analysis/teaching_candidates.json", candidates.model_dump(mode="json")) + learner_model = build_learner_model(profile) + write_json(project_id, "analysis/learner_model.json", learner_model.model_dump(mode="json")) + language_items = build_language_items(candidates, learner_model) + write_json(project_id, "analysis/language_items.json", [item.model_dump(mode="json") for item in language_items]) + + source_lesson = build_source_lesson_profile(source) + write_json(project_id, "analysis/source_lesson_profile.json", source_lesson.model_dump(mode="json")) + difficulty = build_difficulty_profile(source, profile, source_lesson) + write_json(project_id, "analysis/difficulty_profile.json", difficulty.model_dump(mode="json")) + inventory = build_language_inventory(source_lesson, difficulty, learner_model) + for item in language_items: + if item.item_type != "word" or not item.target_form or item.target_form in inventory.known_items: + continue + if item.target_form not in inventory.lesson_target_items: + inventory.lesson_target_items.append(item.target_form) + if item.target_form in inventory.off_level_items: + inventory.off_level_items.remove(item.target_form) + write_json(project_id, "analysis/language_inventory.json", inventory.model_dump(mode="json")) + + from .state_evidence_kernel import build_full_kernel + + state_plan, evidence_plan, activity_plan, alignment = build_full_kernel( + profile, + candidates, + language_items, + str(difficulty.estimated_level) if hasattr(difficulty, "estimated_level") else "zero_beginner", + profile.scaffolding_language or "English", + ) + write_json(project_id, "learning/learning_state_plan.json", state_plan.model_dump(mode="json", by_alias=True)) + write_json(project_id, "learning/evidence_plan.json", evidence_plan.model_dump(mode="json", by_alias=True)) + write_json(project_id, "learning/activity_plan.json", activity_plan.model_dump(mode="json", by_alias=True)) + write_json(project_id, "quality/evidence_alignment_report.json", alignment.model_dump(mode="json", by_alias=True)) + return source, profile, candidates, language_items, learner_model, difficulty, state_plan, evidence_plan, activity_plan, alignment + + +def _compile_blueprint_stage(project_id: str, settings: ProviderSettings) -> _BlueprintStage: + """Run design through canonical presentation and the compatibility adapter.""" + if settings.llm.provider != "deterministic": + raise ProviderError( + f"LLM provider '{settings.llm.provider}' is not migrated to the State-Evidence production contract; " + "complete upstream analysis migration before using it. No direct Source-to-Slides fallback is available." + ) + + _remove_project_artifacts(project_id, PRODUCTION_RESET_ARTIFACTS) + values = _prepare_design_and_kernel(project_id) + ( + source, profile, candidates, language_items, learner_model, difficulty, + state_plan, evidence_plan, activity_plan, alignment, + ) = values + base = dict( + source=source, + profile=profile, + candidates=candidates, + language_items=language_items, + learner_model=learner_model, + difficulty=difficulty, + state_plan=state_plan, + evidence_plan=evidence_plan, + activity_plan=activity_plan, + alignment=alignment, + ) + + abstract_bindings, canonical, shadow_report = write_presentation_canonical_artifacts( + project_id, state_plan, evidence_plan, activity_plan, alignment, + defer_canonical=True, + ) + if alignment.state == "blocked": + write_json(project_id, "quality/kernel_revision_plan.json", { + "schema": "hanclassstudio.kernel_revision_plan.v1", + "state": "blocked", + "blocking_issues": alignment.blocking[:10], + "message": "Evidence alignment blocked. Canonical presentation, legacy compatibility, media, render, and export are stopped.", + }) + return _BlueprintStage(**base, abstract_bindings=abstract_bindings, blocked=True) + if canonical is None or abstract_bindings.state == "blocked": + blocking = list(abstract_bindings.blocking) or ["Canonical presentation compiler produced no blueprint."] + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="canonical_presentation") + return _BlueprintStage(**base, abstract_bindings=abstract_bindings, blocked=True) + + content_plan, content_report, canonical = write_presentation_content_artifacts( + project_id, + state_plan, + evidence_plan, + activity_plan, + abstract_bindings, + canonical, + language_items, + allow_planned_audio=True, + write_canonical=False, + ) + if content_report.state == "blocked": + _write_presentation_revision_artifact(project_id, blocking=content_report.blocking, stage="presentation_content") + return _BlueprintStage(**base, abstract_bindings=abstract_bindings, canonical=canonical, content_plan=content_plan, blocked=True) + + media_request_report = write_presentation_media_request_artifacts(project_id) + media_request_payload = read_json(project_id, "presentation/presentation_media_request_plan.json") + media_request_plan = PresentationMediaRequestPlan.model_validate(media_request_payload) if media_request_payload else None + if media_request_report.state == "blocked" or media_request_plan is None: + blocking = media_request_report.blocking or ["Presentation media request plan is missing."] + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="presentation_media_requests") + return _BlueprintStage(**base, abstract_bindings=abstract_bindings, canonical=canonical, content_plan=content_plan, media_request_plan=media_request_plan, blocked=True) + + # The final canonical artifact is materialized only after its content and + # media-request contracts exist. The in-memory skeleton above is never a + # production source of truth. + write_json( + project_id, + "presentation/presentation_blueprint.json", + canonical.model_dump(mode="json", by_alias=True), + ) + + legacy, mapping, provenance = write_legacy_compatibility_artifacts( + project_id, + canonical, + content_plan, + media_request_plan, + allow_planned_media=True, + ) + if legacy is None or mapping.state == "blocked": + blocking = mapping.blocking or ["Canonical presentation could not be mapped to the legacy renderer contract."] + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="legacy_adapter") + return _BlueprintStage( + **base, + abstract_bindings=abstract_bindings, + canonical=canonical, + content_plan=content_plan, + media_request_plan=media_request_plan, + mapping=mapping, + provenance=provenance, + blocked=True, + ) + + learner_level = str(difficulty.estimated_level) if hasattr(difficulty, "estimated_level") else "zero_beginner" + binding_plan = write_presentation_bindings( + project_id, legacy, evidence_plan, activity_plan, state_plan, learner_level, mapping, + ) + readiness = write_presentation_readiness( + project_id, legacy, evidence_plan, activity_plan, binding_plan, alignment, + binding_strategy="abstract", + ) + if binding_plan.state == "blocked" or readiness.state == "blocked": + blocking = [*binding_plan.blocking, *readiness.blocking] + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="presentation_readiness") + return _BlueprintStage( + **base, + abstract_bindings=abstract_bindings, + canonical=canonical, + content_plan=content_plan, + media_request_plan=media_request_plan, + legacy=legacy, + mapping=mapping, + provenance=provenance, + binding_plan=binding_plan, + readiness=readiness, + blocked=True, + ) + + _mark_presentation_shadow_compatible(project_id, shadow_report) + return _BlueprintStage( + **base, + abstract_bindings=abstract_bindings, + canonical=canonical, + content_plan=content_plan, + media_request_plan=media_request_plan, + legacy=legacy, + mapping=mapping, + provenance=provenance, + binding_plan=binding_plan, + readiness=readiness, + ) + + +def _mark_presentation_shadow_compatible(project_id: str, report) -> None: + """Keep the historical report path while recording a production contract.""" + from .presentation_blueprint import SHADOW_REPORT_PATH + + report.compatibility_contract_valid = True + if "production canonical presentation" not in report.warnings: + report.warnings.append("Canonical presentation and legacy compatibility artifacts are production-owned.") + write_json(project_id, SHADOW_REPORT_PATH, report.model_dump(mode="json", by_alias=True)) + + +def run_blueprint_stage(project_id: str, settings: ProviderSettings) -> ProjectState: + """Run State-Evidence through canonical presentation and stop before media.""" + if production_blueprint_stage_is_current(project_id): + return get_project_state(project_id) + _compile_blueprint_stage(project_id, settings) + return get_project_state(project_id) + + def render_and_check( project_id: str, project_root: Path, @@ -318,127 +890,92 @@ def run_full_pipeline( enable_presentation_media_projection_shadow: bool = False, enable_v2_internal_html_cutover: bool = False, ) -> ProjectState: - source = read_model(project_id, "source_material.json", SourceMaterial) - profile = read_model(project_id, "lesson_profile.json", LessonProfile) - if not source or not profile: - raise ValueError("Project needs source material and lesson profile") + stage = _compile_blueprint_stage(project_id, settings) + if stage.blocked or stage.legacy is None: + return get_project_state(project_id) - shadow_content_enabled = enable_presentation_content_shadow or enable_v2_internal_html_cutover - media_request_enabled = enable_presentation_media_request_shadow or enable_v2_internal_html_cutover - media_projection_enabled = enable_presentation_media_projection_shadow or enable_v2_internal_html_cutover - reconciliation_enabled = ( - enable_presentation_asset_reconciliation_shadow - or media_request_enabled - or media_projection_enabled + # Canonical media requests are production-owned. Existing media providers + # remain renderer-facing for this phase, but receive only the adapted + # compatibility blueprint. + manifest = generate_project_media( + project_root, + stage.legacy, + settings, + preserve_media_origin_trace=True, + strict_provider=True, ) + write_model(project_id, "asset_manifest.json", manifest) + from .presentation_media_requests import run_presentation_media_asset_linkage + from .presentation_asset_reconciliation import run_post_media_presentation_reconciliation + + run_presentation_media_asset_linkage(project_id, manifest) + reconciliation = run_post_media_presentation_reconciliation(project_id, manifest) + if reconciliation.state == "blocked": + _remove_project_artifacts(project_id, ( + "blueprints/lesson_blueprint.json", + "blueprints/interaction_plan.json", + "blueprints/media_plan.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", + "quality/presentation_readiness_report.json", + )) + _write_presentation_revision_artifact( + project_id, + blocking=reconciliation.blocking, + stage="presentation_asset_reconciliation", + ) + return get_project_state(project_id) - write_spec_artifacts(project_id, source, profile) - candidates = extract_candidates(source) - write_json(project_id, "analysis/teaching_candidates.json", candidates.model_dump(mode="json")) - - # Learner model - learner_model = build_learner_model(profile) - write_json(project_id, "analysis/learner_model.json", learner_model.model_dump(mode="json")) - - # Language items - language_items = build_language_items(candidates, learner_model) - write_json(project_id, "analysis/language_items.json", [li.model_dump(mode="json") for li in language_items]) + # Reconciliation updates the canonical content references. Re-run only + # the deterministic adapter and its trace, never the source-to-slides path. + canonical_payload = read_json(project_id, "presentation/presentation_blueprint.json") + content_payload = read_json(project_id, "presentation/presentation_content_plan.reconciled.json") + request_payload = read_json(project_id, "presentation/presentation_media_request_plan.json") + if not canonical_payload or not content_payload: + _write_presentation_revision_artifact( + project_id, + blocking=["Reconciled canonical presentation inputs are missing."], + stage="presentation_asset_reconciliation", + ) + return get_project_state(project_id) + from .models import CanonicalPresentationBlueprint - # Syllabus-aware artifacts - source_lesson = build_source_lesson_profile(source) - write_json(project_id, "analysis/source_lesson_profile.json", source_lesson.model_dump(mode="json")) - difficulty = build_difficulty_profile(source, profile, source_lesson) - write_json(project_id, "analysis/difficulty_profile.json", difficulty.model_dump(mode="json")) - inventory = build_language_inventory(source_lesson, difficulty, learner_model) - for item in language_items: - if item.item_type != "word" or not item.target_form or item.target_form in inventory.known_items: - continue - if item.target_form not in inventory.lesson_target_items: - inventory.lesson_target_items.append(item.target_form) - if item.target_form in inventory.off_level_items: - inventory.off_level_items.remove(item.target_form) - write_json(project_id, "analysis/language_inventory.json", inventory.model_dump(mode="json")) + canonical = CanonicalPresentationBlueprint.model_validate(canonical_payload) + content_plan = PresentationContentPlan.model_validate(content_payload) + media_request_plan = PresentationMediaRequestPlan.model_validate(request_payload) if request_payload else None + legacy, mapping, provenance = write_legacy_compatibility_artifacts( + project_id, canonical, content_plan, media_request_plan, + ) + if legacy is None or mapping.state == "blocked": + _write_presentation_revision_artifact( + project_id, + blocking=mapping.blocking, + stage="legacy_adapter", + ) + return get_project_state(project_id) - # State-Evidence Kernel - from .state_evidence_kernel import build_full_kernel as _build_kernel - state_plan, evidence_plan, activity_plan, alignment = _build_kernel( - profile, candidates, language_items, - str(difficulty.estimated_level) if hasattr(difficulty, "estimated_level") else "zero_beginner", - profile.scaffolding_language or "English", + learner_level = str(stage.difficulty.estimated_level) if hasattr(stage.difficulty, "estimated_level") else "zero_beginner" + binding_plan = write_presentation_bindings( + project_id, legacy, stage.evidence_plan, stage.activity_plan, stage.state_plan, learner_level, mapping, ) - write_json(project_id, "learning/learning_state_plan.json", state_plan.model_dump(mode="json", by_alias=True)) - write_json(project_id, "learning/evidence_plan.json", evidence_plan.model_dump(mode="json", by_alias=True)) - write_json(project_id, "learning/activity_plan.json", activity_plan.model_dump(mode="json", by_alias=True)) - write_json(project_id, "quality/evidence_alignment_report.json", alignment.model_dump(mode="json", by_alias=True)) - shadow_bindings, canonical_shadow, _ = write_presentation_shadow_artifacts( - project_id, state_plan, evidence_plan, activity_plan, alignment, + readiness = write_presentation_readiness( + project_id, legacy, stage.evidence_plan, stage.activity_plan, binding_plan, stage.alignment, + binding_strategy="abstract", ) - if shadow_content_enabled and canonical_shadow is not None: - _, _, canonical_shadow = write_presentation_content_shadow_artifacts( - project_id, state_plan, evidence_plan, activity_plan, shadow_bindings, canonical_shadow, language_items, + if binding_plan.state == "blocked" or readiness.state == "blocked": + _write_presentation_revision_artifact( + project_id, + blocking=[*binding_plan.blocking, *readiness.blocking], + stage="presentation_readiness", ) - if media_request_enabled: - from .presentation_media_requests import run_presentation_media_request_shadow - - run_presentation_media_request_shadow(project_id) - - # Pipeline gate: blocked alignment stops classroom render/export, writes diagnostic artifact - if alignment.state == "blocked": - if enable_presentation_parity_shadow: - from .presentation_parity import run_presentation_parity_harness - - run_presentation_parity_harness(project_id) - if enable_presentation_adapter_assessment: - from .presentation_adapter_assessment import run_presentation_adapter_assessment - - run_presentation_adapter_assessment(project_id) - from .storage import write_json as _wj, ensure_project as _ep - _wj(project_id, "quality/kernel_revision_plan.json", { - "schema": "hanclassstudio.kernel_revision_plan.v1", - "state": "blocked", - "blocking_issues": alignment.blocking[:10], - "message": "Evidence alignment blocked. Classroom render/export stopped. Diagnostic artifact generated.", - }) - # Generate diagnostic ZIP with kernel artifacts only - import zipfile, datetime - diag_root = _ep(project_id) - diag_ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") - diag_path = diag_root / "exports" / f"HanClassStudio_Kernel_Diagnostic_{diag_ts}.zip" - diag_path.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(diag_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: - for src_name, arc_name in [ - ("learning/learning_state_plan.json", "learning/learning_state_plan.json"), - ("learning/evidence_plan.json", "learning/evidence_plan.json"), - ("learning/activity_plan.json", "learning/activity_plan.json"), - ("quality/evidence_alignment_report.json", "quality/evidence_alignment_report.json"), - ("presentation/abstract_activity_bindings.json", "presentation/abstract_activity_bindings.json"), - ("quality/presentation_shadow_report.json", "quality/presentation_shadow_report.json"), - ("quality/kernel_revision_plan.json", "kernel_revision_plan.json"), - ("sources/source_material.json", "source_material.json"), - ]: - fp = diag_root / src_name - if fp.exists(): - zf.write(fp, arc_name) - _wj(project_id, "exports/export_manifest.json", { - "project_id": project_id, - "created_at": datetime.datetime.now().isoformat(timespec="seconds"), - "export_type": "kernel_diagnostic", - "diagnostic": True, - "kernel_alignment_state": "blocked", - }) - if enable_v2_internal_html_cutover: - from .v2_cutover_readiness import run_v2_internal_html_cutover - - run_v2_internal_html_cutover( - project_id, project_root, profile, AssetManifest(), QualityReport(), - enabled=True, require_courseware_review=False, - ) - # Return project state without generating presentation or rendered artifacts. return get_project_state(project_id) - # Presentation remains downstream from the State-Evidence alignment gate. - blueprint, _ = generate_lesson_blueprint(source, profile, settings, candidates, language_items, project_id) - write_blueprint_artifacts(project_id, blueprint) + if enable_presentation_media_projection_shadow or enable_v2_internal_html_cutover: + from .presentation_media_projection import run_presentation_media_projection_audit + + run_presentation_media_projection_audit(project_id, manifest) if enable_presentation_parity_shadow: from .presentation_parity import run_presentation_parity_harness @@ -447,100 +984,33 @@ def run_full_pipeline( from .presentation_adapter_assessment import run_presentation_adapter_assessment run_presentation_adapter_assessment(project_id) - learner_level = str(difficulty.estimated_level) if hasattr(difficulty, "estimated_level") else "zero_beginner" - binding_plan = write_presentation_bindings(project_id, blueprint, evidence_plan, activity_plan, state_plan, learner_level) - readiness = write_presentation_readiness( - project_id, blueprint, evidence_plan, activity_plan, binding_plan, alignment, - ) - if binding_plan.state == "blocked" or readiness.state == "blocked": - manifest = generate_project_media(project_root, blueprint, settings, media_projection_enabled, strict_provider=True) - write_model(project_id, "asset_manifest.json", manifest) - if media_projection_enabled: - from .presentation_media_projection import run_presentation_media_projection_audit - - run_presentation_media_projection_audit(project_id, manifest) - if media_request_enabled: - from .presentation_media_requests import run_presentation_media_asset_linkage - - run_presentation_media_asset_linkage(project_id, manifest) - if reconciliation_enabled: - from .presentation_asset_reconciliation import run_post_media_presentation_reconciliation - - run_post_media_presentation_reconciliation(project_id, manifest) - if enable_v2_internal_html_cutover: - from .v2_cutover_readiness import run_v2_internal_html_cutover - - run_v2_internal_html_cutover( - project_id, project_root, profile, manifest, QualityReport(), - enabled=True, require_courseware_review=False, - ) - return get_project_state(project_id) - manifest = generate_project_media(project_root, blueprint, settings, media_projection_enabled, strict_provider=True) - write_model(project_id, "asset_manifest.json", manifest) - if media_projection_enabled: - from .presentation_media_projection import run_presentation_media_projection_audit - - run_presentation_media_projection_audit(project_id, manifest) - if media_request_enabled: - from .presentation_media_requests import run_presentation_media_asset_linkage - - run_presentation_media_asset_linkage(project_id, manifest) - if reconciliation_enabled: - from .presentation_asset_reconciliation import run_post_media_presentation_reconciliation - - run_post_media_presentation_reconciliation(project_id, manifest) write_json(project_id, "assets/data/attribution.json", {"schema": "hanclassstudio.attribution.v1", "items": []}) - report = render_and_check(project_id, project_root, profile, blueprint, manifest, candidates, language_items, learner_model) - # Revision application: if review was blocked, try auto-fix - rev_path = project_root / "blueprints" / "revision_plan.json" - if rev_path.exists() and report.state == "blocked": - from .review_agent import apply_revision_plan, review_blueprint as _review_again - from .models import RevisionPlan as _RP - from .storage import read_json as _rj - rev_data = _rj(project_id, "blueprints/revision_plan.json") - rev_plan = _RP(**rev_data) if rev_data else None - zb = "zero_beginner" if profile.learner_level and "zero" in profile.learner_level.lower() else "beginner" - revised_bp, rev_apply_report = apply_revision_plan(blueprint, rev_plan, learner_model, None, language_items) - normalize_component_ids(revised_bp) - write_json(project_id, "blueprints/revised_blueprint.json", revised_bp.model_dump(mode="json")) - write_json(project_id, "quality/revision_application_report.json", rev_apply_report) - revised_review = _review_again(revised_bp, zb, profile.scaffolding_language or "English", language_items) - write_json(project_id, "quality/revised_review_report.json", revised_review.model_dump(mode="json")) - if revised_review.state != "blocked": - blueprint = revised_bp - write_blueprint_artifacts(project_id, revised_bp) - binding_plan = write_presentation_bindings(project_id, blueprint, evidence_plan, activity_plan, state_plan, learner_level) - readiness = write_presentation_readiness( - project_id, blueprint, evidence_plan, activity_plan, binding_plan, alignment, - ) - if binding_plan.state == "blocked" or readiness.state == "blocked": - return get_project_state(project_id) - manifest = generate_project_media(project_root, blueprint, settings, media_projection_enabled, strict_provider=True) - write_model(project_id, "asset_manifest.json", manifest) - if media_projection_enabled: - from .presentation_media_projection import run_presentation_media_projection_audit - - run_presentation_media_projection_audit(project_id, manifest) - if media_request_enabled: - from .presentation_media_requests import run_presentation_media_asset_linkage - - run_presentation_media_asset_linkage(project_id, manifest) - if reconciliation_enabled: - from .presentation_asset_reconciliation import run_post_media_presentation_reconciliation - - run_post_media_presentation_reconciliation(project_id, manifest) - report = render_and_check(project_id, project_root, profile, blueprint, manifest, candidates, language_items, learner_model) - # End revision application + report = render_and_check( + project_id, + project_root, + stage.profile, + legacy, + manifest, + stage.candidates, + stage.language_items, + stage.learner_model, + ) if enable_v2_internal_html_cutover: from .v2_cutover_readiness import run_v2_internal_html_cutover run_v2_internal_html_cutover( - project_id, project_root, profile, manifest, report, + project_id, project_root, stage.profile, manifest, report, enabled=True, require_courseware_review=True, ) - if report.state != "blocked" or force_export: + # A blocked quality report produces review/revision artifacts only. It may + # never mutate the adapter output or silently fall back to a legacy author. + if report.state != "blocked": + clear_stale_state( + project_id, + stages={"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"}, + ) zip_output(project_id, force=force_export) return get_project_state(project_id) diff --git a/apps/api/src/hcs_api/presentation_asset_reconciliation.py b/apps/api/src/hcs_api/presentation_asset_reconciliation.py index 4a783b0..cc9228b 100644 --- a/apps/api/src/hcs_api/presentation_asset_reconciliation.py +++ b/apps/api/src/hcs_api/presentation_asset_reconciliation.py @@ -1,4 +1,4 @@ -"""Post-media, shadow-only reconciliation of traceable audio asset references.""" +"""Post-media reconciliation of canonical content and traceable asset references.""" from __future__ import annotations @@ -297,9 +297,9 @@ def _recompute_downstream(project_id: str, report: PresentationAssetReconciliati "quality/presentation_adapter_assessment_report.json", ]) if parity.state == "blocked": - _block(report, "Recomputed presentation parity report is blocked.") + _warn(report, "Diagnostic presentation parity report is blocked; it does not change production artifact authority.") if assessment.state == "blocked": - _block(report, "Recomputed presentation adapter assessment report is blocked.") + _warn(report, "Diagnostic adapter assessment report is blocked; the production readiness gate remains authoritative.") def _block(report: PresentationAssetReconciliationReport, message: str) -> None: diff --git a/apps/api/src/hcs_api/presentation_blueprint.py b/apps/api/src/hcs_api/presentation_blueprint.py index 6337a75..995bfec 100644 --- a/apps/api/src/hcs_api/presentation_blueprint.py +++ b/apps/api/src/hcs_api/presentation_blueprint.py @@ -1,4 +1,4 @@ -"""Shadow-only binding-first presentation compiler. +"""Binding-first canonical presentation compiler. This module reads the State-Evidence kernel artifacts only. It intentionally does not import or read the legacy lesson blueprint or renderer contracts. @@ -32,13 +32,13 @@ SHADOW_REPORT_PATH = "quality/presentation_shadow_report.json" -def compile_shadow_presentation( +def compile_canonical_presentation( state_plan: LearningStatePlan, evidence_plan: EvidencePlan, activity_plan: ActivityPlan, alignment_report: EvidenceAlignmentReport, ) -> tuple[AbstractPresentationBindingPlan, CanonicalPresentationBlueprint | None, PresentationShadowReport]: - """Compile kernel artifacts into a non-production presentation projection.""" + """Compile kernel artifacts into the authoritative presentation contract.""" bindings = build_abstract_presentation_bindings(evidence_plan, activity_plan, alignment_report) if bindings.state == "blocked": return bindings, None, PresentationShadowReport( @@ -58,6 +58,20 @@ def compile_shadow_presentation( ) +def compile_shadow_presentation( + state_plan: LearningStatePlan, + evidence_plan: EvidencePlan, + activity_plan: ActivityPlan, + alignment_report: EvidenceAlignmentReport, +) -> tuple[AbstractPresentationBindingPlan, CanonicalPresentationBlueprint | None, PresentationShadowReport]: + """Backward-compatible diagnostic alias for the canonical compiler. + + Existing parity fixtures may still call this name. Production code uses + :func:`compile_canonical_presentation` directly. + """ + return compile_canonical_presentation(state_plan, evidence_plan, activity_plan, alignment_report) + + def build_abstract_presentation_bindings( evidence_plan: EvidencePlan, activity_plan: ActivityPlan, @@ -131,7 +145,8 @@ def build_canonical_presentation_blueprint( warnings=list(bindings.warnings), source_artifacts=list(KERNEL_SOURCE_ARTIFACTS), compatibility_notes=[ - "Shadow-only v2 artifact; production renderers continue to use the legacy presentation contract.", + "Canonical presentation authority is compiled from State-Evidence artifacts.", + "Legacy renderers consume only the deterministic compatibility adapter output.", "Teacher-only content is referenced by channel and is not included in learner_facing_content.", ], ) diff --git a/apps/api/src/hcs_api/presentation_content.py b/apps/api/src/hcs_api/presentation_content.py index a299d89..521b64a 100644 --- a/apps/api/src/hcs_api/presentation_content.py +++ b/apps/api/src/hcs_api/presentation_content.py @@ -1,4 +1,4 @@ -"""Shadow-only component-neutral content planning for approved presentation units.""" +"""Component-neutral content planning for approved presentation units.""" from __future__ import annotations @@ -41,8 +41,15 @@ def build_presentation_content_plan( canonical_blueprint: CanonicalPresentationBlueprint | None = None, language_items: list[LanguageItem] | None = None, asset_manifest: AssetManifest | None = None, + allow_planned_audio: bool = False, ) -> tuple[PresentationContentPlan, PresentationContentReport]: - """Project approved artifacts into content; no mode or pedagogy is selected here.""" + """Project approved artifacts into content; no mode or pedagogy is selected here. + + The production presentation stage runs once before media generation. That + pass may carry deterministic ``planned`` audio references so the media + request stage can be compiled before assets exist. Reconciliation uses the + default strict mode and requires an available AssetManifest entry. + """ language_items = language_items or [] assets = asset_manifest or AssetManifest() evidence_by_id = {item.evidence_id: item for item in evidence_plan.evidence_specs} @@ -52,7 +59,16 @@ def build_presentation_content_plan( units = canonical_blueprint.presentation_units if canonical_blueprint else _units_from_bindings(binding_plan) for unit in units: - item = _content_for_unit(unit, evidence_by_id, activity_by_id, binding_by_id, language_items, assets) + item = _content_for_unit( + unit, + evidence_by_id, + activity_by_id, + binding_by_id, + language_items, + assets, + all_evidence=list(evidence_plan.evidence_specs), + allow_planned_audio=allow_planned_audio, + ) items.append(item) plan = PresentationContentPlan( @@ -62,18 +78,25 @@ def build_presentation_content_plan( source_artifacts=list(CONTENT_SOURCE_ARTIFACTS) + (["assets/data/asset_manifest.json"] if asset_manifest else []), trace=[item.trace for item in items], ) - report = evaluate_presentation_content_plan(plan) + report = evaluate_presentation_content_plan(plan, allow_planned_audio=allow_planned_audio) plan.warnings = list(report.warnings) return plan, report -def evaluate_presentation_content_plan(plan: PresentationContentPlan) -> PresentationContentReport: +def evaluate_presentation_content_plan( + plan: PresentationContentPlan, + *, + allow_planned_audio: bool = False, +) -> PresentationContentReport: """Evaluate an initial or reconciled plan without regenerating learner content.""" report = PresentationContentReport(source_artifacts_checked=list(plan.source_artifacts)) for item in plan.content_items: - _record_item(report, item) + _record_item(report, item, allow_planned_audio=allow_planned_audio) report.items_count = len(plan.content_items) - report.complete_items_count = sum(content_item_is_complete(item) for item in plan.content_items) + report.complete_items_count = sum( + content_item_is_complete(item, allow_planned_audio=allow_planned_audio) + for item in plan.content_items + ) report.incomplete_items_count = report.items_count - report.complete_items_count expected = {item.presentation_unit_id for item in plan.content_items} traced = {item.presentation_unit_id for item in plan.content_items if item.trace.presentation_unit_id == item.presentation_unit_id} @@ -85,14 +108,21 @@ def evaluate_presentation_content_plan(plan: PresentationContentPlan) -> Present return report -def content_item_is_complete(item: PresentationContentItem) -> bool: +def content_item_is_complete(item: PresentationContentItem, *, allow_planned_audio: bool = False) -> bool: """Evaluate required payload presence without mutating planned learner content.""" if item.presentation_mode == "teacher_observation": return True if item.presentation_mode == "listening_choice": - return bool(item.prompt and len(item.options) >= 2 and item.accepted_responses and any( - ref.availability == "available" for ref in item.audio_asset_refs - )) + return bool( + item.prompt + and len(item.options) >= 2 + and item.accepted_responses + and any( + ref.availability == "available" + or (allow_planned_audio and ref.availability == "planned") + for ref in item.audio_asset_refs + ) + ) if item.presentation_mode == "matching_response": return len(item.matching_pairs) >= 2 and _unambiguous_pairs(item.matching_pairs) return item.complete @@ -140,7 +170,17 @@ def attach_content_references( ) -def _content_for_unit(unit, evidence_by_id, activity_by_id, binding_by_id, language_items, assets) -> PresentationContentItem: +def _content_for_unit( + unit, + evidence_by_id, + activity_by_id, + binding_by_id, + language_items, + assets, + *, + all_evidence: list | None = None, + allow_planned_audio: bool = False, +) -> PresentationContentItem: warnings: list[str] = [] evidence = [evidence_by_id.get(evidence_id) for evidence_id in unit.evidence_ids] missing_evidence = [evidence_id for evidence_id, spec in zip(unit.evidence_ids, evidence) if spec is None] @@ -184,9 +224,19 @@ def _content_for_unit(unit, evidence_by_id, activity_by_id, binding_by_id, langu item.accepted_responses = accepted if unit.presentation_mode in {"choice_response", "listening_choice"}: - item.options = _choice_options(accepted, evidence, language_items, unit.presentation_unit_id) + item.options = _choice_options( + accepted, + evidence, + language_items, + unit.presentation_unit_id, + all_evidence or evidence, + ) if unit.presentation_mode == "listening_choice": - item.audio_asset_refs = _audio_refs(item.display_items, accepted, assets, item.id) + item.audio_asset_refs = _audio_refs( + item.display_items, accepted, assets, item.id, + unit.presentation_unit_id, item.language_items, + allow_planned_audio=allow_planned_audio, + ) item.complete = bool(item.prompt and len(item.options) >= 2 and accepted) if unit.presentation_mode == "listening_choice": item.complete = item.complete and any(ref.availability == "available" for ref in item.audio_asset_refs) @@ -202,7 +252,12 @@ def _content_for_unit(unit, evidence_by_id, activity_by_id, binding_by_id, langu return item -def _record_item(report: PresentationContentReport, item: PresentationContentItem) -> None: +def _record_item( + report: PresentationContentReport, + item: PresentationContentItem, + *, + allow_planned_audio: bool = False, +) -> None: mode = item.presentation_mode if mode == "choice_response": report.choice_items_count += 1 @@ -221,7 +276,11 @@ def _record_item(report: PresentationContentReport, item: PresentationContentIte if not item.accepted_responses: report.missing_accepted_responses.append(item.presentation_unit_id) _block(report, f"{mode} item '{item.presentation_unit_id}' has no accepted-response projection.") - if mode == "listening_choice" and not any(ref.availability == "available" for ref in item.audio_asset_refs): + if mode == "listening_choice" and not any( + ref.availability == "available" + or (allow_planned_audio and ref.availability == "planned") + for ref in item.audio_asset_refs + ): report.missing_audio_assets.append(item.presentation_unit_id) _block(report, f"listening_choice item '{item.presentation_unit_id}' has no available audio asset reference.") if mode == "matching_response" and (len(item.matching_pairs) < 2 or not _unambiguous_pairs(item.matching_pairs)): @@ -257,8 +316,8 @@ def _accepted_responses(evidence: list) -> list[AcceptedResponse]: values.append((explicit, "evidence.acceptable_response")) elif isinstance(explicit, list): values.extend((str(value), "evidence.acceptable_response") for value in explicit if value) - elif len(spec.target_items) == 1: - values.append((spec.target_items[0], "evidence.target_items")) + elif spec.target_items: + values.extend((value, "evidence.target_items") for value in spec.target_items if value) return [ AcceptedResponse( value=value, @@ -272,9 +331,11 @@ def _accepted_responses(evidence: list) -> list[AcceptedResponse]: ] -def _choice_options(accepted, evidence, language_items, unit_id: str) -> list[ChoiceOption]: +def _choice_options(accepted, evidence, language_items, unit_id: str, all_evidence: list) -> list[ChoiceOption]: accepted_values = {item.normalized_value for item in accepted} - candidates = list(accepted_values) + _target_items(evidence) + [item.target_form for item in language_items if item.target_form] + current_targets = set(_target_items(evidence)) + approved_targets = [value for value in _target_items(all_evidence) if value not in current_targets] + candidates = list(accepted_values) + _target_items(evidence) + approved_targets + [item.target_form for item in language_items if item.target_form] values = list(dict.fromkeys(value for value in candidates if value)) return [ ChoiceOption( @@ -282,7 +343,13 @@ def _choice_options(accepted, evidence, language_items, unit_id: str) -> list[Ch text=value, value=value, is_accepted=value.strip() in accepted_values, - provenance=["evidence.acceptable_response" if value.strip() in accepted_values else "analysis/language_items.json"], + provenance=[ + "evidence.acceptable_response" + if value.strip() in accepted_values + else "learning/evidence_plan.json" + if value in approved_targets + else "analysis/language_items.json" + ], ) for index, value in enumerate(values[:4], start=1) ] @@ -306,7 +373,16 @@ def _unambiguous_pairs(pairs: list[MatchingPair]) -> bool: return len({pair.id for pair in pairs}) == len(pairs) and len({pair.left for pair in pairs}) == len(pairs) and len({pair.right for pair in pairs}) == len(pairs) -def _audio_refs(display_items, accepted, assets: AssetManifest, item_id: str) -> list[AssetReference]: +def _audio_refs( + display_items, + accepted, + assets: AssetManifest, + item_id: str, + unit_id: str, + language_ids: list[str], + *, + allow_planned_audio: bool = False, +) -> list[AssetReference]: target_values = set(display_items) | {item.normalized_value for item in accepted} matches = [asset for asset in assets.audio if asset.text in target_values and asset.path] if matches: @@ -332,6 +408,22 @@ def _audio_refs(display_items, accepted, assets: AssetManifest, item_id: str) -> ) for asset in planned ] + if allow_planned_audio: + from .presentation_media_requests import media_request_id_for_content + + source_text = next((item.normalized_value for item in accepted if item.normalized_value), next(iter(target_values), "")) + request_id = media_request_id_for_content( + item_id, unit_id, "audio", "listening_prompt", source_text, language_ids, + ) + return [ + AssetReference( + asset_id=request_id, + asset_type="audio", + path_or_key="", + availability="planned", + provenance=["presentation/presentation_media_request_plan.json", request_id], + ) + ] return [ AssetReference( asset_id="", diff --git a/apps/api/src/hcs_api/presentation_media_requests.py b/apps/api/src/hcs_api/presentation_media_requests.py index babe0fc..eb01953 100644 --- a/apps/api/src/hcs_api/presentation_media_requests.py +++ b/apps/api/src/hcs_api/presentation_media_requests.py @@ -1,4 +1,4 @@ -"""Shadow-only deterministic media request identities for presentation content.""" +"""Deterministic media request identities for approved presentation content.""" from __future__ import annotations @@ -77,13 +77,18 @@ def build_presentation_media_request_plan( if report.trace_coverage != 1.0: _block(report, "Media request trace coverage is incomplete.") if requests: - _warn(report, "Shadow media requests are planned only; production media generation does not consume them.") + _warn(report, "Media requests are deterministic production identities; generated assets are reconciled through the legacy media contract.") report.state = "blocked" if report.blocking else "warning" if report.warnings else "pass" - report.notes.append("AssetManifest has no direct request-trace field; post-media shadow linkage is used.") + report.notes.append("AssetManifest has no direct request-trace field; post-media reconciliation preserves request provenance.") return plan, report def run_presentation_media_request_shadow(project_id: str) -> PresentationMediaRequestReport: + """Backward-compatible diagnostic alias for the production request stage.""" + return run_presentation_media_request_plan(project_id) + + +def run_presentation_media_request_plan(project_id: str) -> PresentationMediaRequestReport: payload = read_json(project_id, CONTENT_PLAN_PATH) if payload is None: report = PresentationMediaRequestReport(state="blocked", blocking=[f"Missing content plan at '{CONTENT_PLAN_PATH}'."]) @@ -173,11 +178,16 @@ def run_presentation_media_asset_linkage(project_id: str, asset_manifest: AssetM return links -def _request_id(content_item_id: str, unit_id: str, media_type: str, media_role: str, source_text: str, language_ids: list[str]) -> str: +def media_request_id_for_content(content_item_id: str, unit_id: str, media_type: str, media_role: str, source_text: str, language_ids: list[str]) -> str: identity = "|".join([NAMESPACE, content_item_id, unit_id, media_type, media_role, source_text.strip(), *sorted(language_ids)]) return f"pmr_{sha256(identity.encode('utf-8')).hexdigest()[:16]}" +def _request_id(content_item_id: str, unit_id: str, media_type: str, media_role: str, source_text: str, language_ids: list[str]) -> str: + """Compatibility alias retained for existing fixtures.""" + return media_request_id_for_content(content_item_id, unit_id, media_type, media_role, source_text, language_ids) + + def _source_text(item) -> str: if item.accepted_responses: return item.accepted_responses[0].normalized_value diff --git a/apps/api/src/hcs_api/presentation_parity.py b/apps/api/src/hcs_api/presentation_parity.py index 0c42b49..dfee407 100644 --- a/apps/api/src/hcs_api/presentation_parity.py +++ b/apps/api/src/hcs_api/presentation_parity.py @@ -140,11 +140,17 @@ def _load_optional(project_id: str, path: str, model_type, report: PresentationP def _adapt(adapter, canonical, content_plan): if content_plan is None: - return adapter(canonical) + try: + return adapter(canonical, include_diagnostic_trace=True) + except TypeError: + return adapter(canonical) try: - return adapter(canonical, content_plan) + return adapter(canonical, content_plan, include_diagnostic_trace=True) except TypeError: - return adapter(canonical) + try: + return adapter(canonical, content_plan) + except TypeError: + return adapter(canonical) def _component_count(blueprint: LessonBlueprint) -> int: diff --git a/apps/api/src/hcs_api/storage.py b/apps/api/src/hcs_api/storage.py index 1a92cd0..b9368e1 100644 --- a/apps/api/src/hcs_api/storage.py +++ b/apps/api/src/hcs_api/storage.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib import os import shutil import tempfile @@ -86,6 +87,8 @@ "presentation/binding_quality_report.json", "presentation/abstract_activity_bindings.json", "presentation/presentation_blueprint.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", "presentation/legacy_blueprint_from_v2.shadow.json", "presentation/legacy_component_mapping.shadow.json", "presentation/presentation_content_plan.json", @@ -115,6 +118,7 @@ "quality/presentation_content_report.json", "quality/presentation_asset_reconciliation_report.json", "quality/presentation_media_request_report.json", + "quality/presentation_revision_plan.json", "quality/presentation_media_projection_report.json", "quality/quality_report.json", "quality/quality_summary.md", @@ -191,6 +195,20 @@ def read_json(project_id: str, relative_path: str | Path) -> Any | None: return None +def artifact_fingerprint(project_id: str, relative_path: str | Path) -> str | None: + """Return a stable fingerprint for a JSON artifact when it exists.""" + payload = read_json(project_id, relative_path) + if payload is None: + return None + serialized = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(serialized).hexdigest() + + def write_model(project_id: str, filename: str, model: BaseModel) -> None: path = artifact_path(project_id, filename) path.parent.mkdir(parents=True, exist_ok=True) @@ -304,10 +322,11 @@ def clear_stale_state(project_id: str, *, stages: set[str]) -> None: def invalidate_downstream(project_id: str, dependency: str, reason: str) -> None: """Mark current downstream artifacts stale without deleting historical evidence.""" downstream = { - "source": {"profile", "design", "presentation", "media", "render", "quality", "delivery"}, - "ocr": {"profile", "design", "presentation", "media", "render", "quality", "delivery"}, - "profile": {"design", "presentation", "media", "render", "quality", "delivery"}, + "source": {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"}, + "ocr": {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"}, + "profile": {"learning", "design", "presentation", "media", "render", "quality", "delivery"}, "design": {"presentation", "media", "render", "quality", "delivery"}, + "learning": {"presentation", "media", "render", "quality", "delivery"}, "blueprint": {"media", "render", "quality", "delivery"}, "media": {"render", "quality", "delivery"}, "render": {"quality", "delivery"}, @@ -332,7 +351,7 @@ def _effective_stale_state( stages = set(stored.stale_stages) reasons = list(stored.reasons) profile_state = read_profile_state(project_id, profile) - all_downstream = {"profile", "design", "presentation", "media", "render", "quality", "delivery"} + all_downstream = {"profile", "learning", "design", "presentation", "media", "render", "quality", "delivery"} if profile_state == "stale": stages.update(all_downstream) if "Profile confirmation is stale; downstream artifacts require regeneration." not in reasons: @@ -348,6 +367,40 @@ def _effective_stale_state( if legacy_reason not in reasons: reasons.append(legacy_reason) + if blueprint and blueprint.artifact_role == "legacy_compatibility": + provenance = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + expected_fingerprint = provenance.get("legacy_blueprint_fingerprint") if isinstance(provenance, dict) else None + if expected_fingerprint: + actual_payload = json.dumps( + blueprint.model_dump(mode="json"), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + actual_fingerprint = hashlib.sha256(actual_payload).hexdigest() + if actual_fingerprint != expected_fingerprint: + stages.update({"presentation", "media", "render", "quality", "delivery"}) + reason = "Legacy compatibility Blueprint changed after canonical compilation; rerun presentation compilation." + if reason not in reasons: + reasons.append(reason) + upstream_fingerprints = provenance.get("upstream_artifact_fingerprints", {}) if isinstance(provenance, dict) else {} + if isinstance(upstream_fingerprints, dict): + changed_upstream = [ + path + for path, expected in upstream_fingerprints.items() + if isinstance(path, str) + and isinstance(expected, str) + and artifact_fingerprint(project_id, path) != expected + ] + if changed_upstream: + stages.update({"presentation", "media", "render", "quality", "delivery"}) + reason = ( + "State-Evidence upstream artifacts changed after canonical compilation; " + "rerun presentation compilation." + ) + if reason not in reasons: + reasons.append(reason) + return stored.model_copy(update={ "stale": bool(stored.stale or stages), "stale_stages": sorted(stages), @@ -458,6 +511,9 @@ def get_project_state(project_id: str) -> ProjectState: "source_material": source is not None, "lesson_profile": profile is not None, "lesson_blueprint": blueprint is not None, + "canonical_presentation": (root / "presentation/presentation_blueprint.json").is_file(), + "legacy_component_mapping": (root / "presentation/legacy_component_mapping.json").is_file(), + "legacy_blueprint_provenance": (root / "presentation/legacy_blueprint_provenance.json").is_file(), "asset_manifest": manifest is not None, "render": lesson_exists, "quality_report": report is not None, @@ -566,7 +622,7 @@ def _gate_summary( readiness = _gate_status(project_id, "quality/presentation_readiness_report.json") binding = _gate_status(project_id, "presentation/binding_quality_report.json") quality = _gate_status(project_id, "quality/quality_report.json") - if stale_stages.intersection({"source", "ocr", "profile", "design"}): + if stale_stages.intersection({"source", "ocr", "profile", "learning", "design"}): evidence = _mark_gate_stale(evidence) if stale_stages.intersection({"presentation", "media"}): readiness = _mark_gate_stale(readiness) @@ -579,7 +635,14 @@ def _gate_summary( root = project_dir(project_id) lesson_path = root / "courseware" / "lesson.html" if blueprint is None: - technical_blockers.append("Blueprint artifact is missing") + technical_blockers.append("Legacy compatibility blueprint artifact is missing") + for path, label in ( + ("presentation/presentation_blueprint.json", "Canonical presentation blueprint"), + ("presentation/legacy_component_mapping.json", "Legacy component mapping"), + ("presentation/legacy_blueprint_provenance.json", "Legacy blueprint provenance"), + ): + if not (root / path).is_file(): + technical_blockers.append(f"{label} artifact is missing") if (render_reason := _render_artifact_reason(lesson_path)) is not None: technical_blockers.append(render_reason) @@ -727,9 +790,18 @@ def _project_stages( StageStatus( stage_id="presentation", state=presentation_state, - required_artifacts=["blueprints/lesson_blueprint.json", "presentation/activity_bindings.json"], + required_artifacts=[ + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "blueprints/lesson_blueprint.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "presentation/activity_bindings.json", + ], blockers=presentation_blockers, - available_actions=["edit_blueprint", "generate_media"] if blueprint else ["generate_blueprint"], + available_actions=["generate_media"] if blueprint else ["generate_blueprint"], ), StageStatus( stage_id="quality", @@ -756,7 +828,7 @@ def _project_stages( ] stale_aliases = { "profile": {"profile"}, - "design": {"design"}, + "design": {"design", "learning"}, "presentation": {"presentation", "media"}, "quality": {"render", "quality"}, "delivery": {"delivery"}, @@ -893,6 +965,16 @@ def zip_output(project_id: str, force: bool = False, classroom: bool = False) -> extra_data = { "sources/source_material.json": "assets/data/source_material.json", + "learning/learning_state_plan.json": "assets/data/learning_state_plan.json", + "learning/evidence_plan.json": "assets/data/evidence_plan.json", + "learning/activity_plan.json": "assets/data/activity_plan.json", + "quality/evidence_alignment_report.json": "assets/data/evidence_alignment_report.json", + "presentation/abstract_activity_bindings.json": "assets/data/abstract_activity_bindings.json", + "presentation/presentation_blueprint.json": "assets/data/presentation_blueprint.json", + "presentation/presentation_content_plan.json": "assets/data/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json": "assets/data/presentation_media_request_plan.json", + "presentation/legacy_component_mapping.json": "assets/data/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json": "assets/data/legacy_blueprint_provenance.json", "blueprints/lesson_blueprint.json": "assets/data/lesson_blueprint.json", "blueprints/interaction_plan.json": "assets/data/interaction_plan.json", "blueprints/media_plan.json": "assets/data/media_plan.json", @@ -913,6 +995,13 @@ def zip_output(project_id: str, force: bool = False, classroom: bool = False) -> def _assert_export_technical_artifacts(project_id: str, root: Path) -> None: if read_model(project_id, "lesson_blueprint.json", LessonBlueprint) is None: raise PermissionError("Blueprint artifact is missing; export cannot proceed") + for path, label in ( + ("presentation/presentation_blueprint.json", "Canonical presentation blueprint"), + ("presentation/legacy_component_mapping.json", "Legacy component mapping"), + ("presentation/legacy_blueprint_provenance.json", "Legacy blueprint provenance"), + ): + if not (root / path).is_file(): + raise PermissionError(f"{label} artifact is missing; export cannot proceed") lesson_path = root / "courseware" / "lesson.html" if (reason := _render_artifact_reason(lesson_path)) is not None: raise PermissionError(f"{reason}; export cannot proceed") diff --git a/apps/api/src/hcs_api/v2_cutover_readiness.py b/apps/api/src/hcs_api/v2_cutover_readiness.py index 5ea35d0..5951a92 100644 --- a/apps/api/src/hcs_api/v2_cutover_readiness.py +++ b/apps/api/src/hcs_api/v2_cutover_readiness.py @@ -195,6 +195,7 @@ def run_v2_internal_html_cutover( legacy_before = legacy_html.read_bytes() if legacy_html.exists() else None render_lesson( project_root, profile, adapted, manifest, quality_report, + render_mode="diagnostic", output_filename=Path(INTERNAL_HTML_PATH).name, ) rendered_review = run_v2_rendered_output_review( @@ -405,7 +406,7 @@ def _check_report_warnings(report, adapter, parity, request) -> None: _block(report, f"Parity warning is not accepted for the internal experiment: {warning}") _warn(report, f"Parity: {warning}") for warning in request.warnings: - if "planned only" not in warning: + if "planned only" not in warning and "production identities" not in warning: _block(report, f"Media-request warning is not accepted for the internal experiment: {warning}") _warn(report, f"Media request: {warning}") @@ -448,7 +449,13 @@ def _surface_gate_warnings(report, reports: dict[str, Any]) -> None: def _adapt(report, canonical, content) -> LessonBlueprint | None: try: - adapted = LessonBlueprint.model_validate(adapt_canonical_presentation_blueprint(canonical, content).model_dump(mode="json")) + adapted = LessonBlueprint.model_validate( + adapt_canonical_presentation_blueprint( + canonical, + content, + include_diagnostic_trace=True, + ).model_dump(mode="json") + ) except Exception as exc: _block(report, f"Compatibility adapter cannot produce a LessonBlueprint input: {exc}") return None From 0849510199a1bdffb6d2146d25ece2121c84f2f6 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:15 +0700 Subject: [PATCH 03/16] feat: adapt canonical presentation into legacy renderer contract --- .../src/hcs_api/blueprint_compatibility.py | 245 +++++++++++++++--- .../presentation_adapter_assessment.py | 30 ++- apps/api/src/hcs_api/presentation_bindings.py | 17 +- .../api/src/hcs_api/presentation_readiness.py | 9 +- apps/api/src/hcs_api/quality.py | 6 +- apps/api/src/hcs_api/renderer.py | 42 ++- 6 files changed, 286 insertions(+), 63 deletions(-) diff --git a/apps/api/src/hcs_api/blueprint_compatibility.py b/apps/api/src/hcs_api/blueprint_compatibility.py index 681f9f7..1dd826d 100644 --- a/apps/api/src/hcs_api/blueprint_compatibility.py +++ b/apps/api/src/hcs_api/blueprint_compatibility.py @@ -1,67 +1,232 @@ -"""Shadow adapter from the canonical v2 presentation contract to the legacy shape.""" +"""Deterministic adapter from canonical presentation to legacy render inputs.""" from __future__ import annotations -from .models import CanonicalPresentationBlueprint, ContentBlock, LessonBlueprint, LessonSlide, PresentationContentPlan, SlideComponent +import hashlib +import json + +from .models import ( + CanonicalPresentationBlueprint, + ContentBlock, + LegacyBlueprintProvenance, + LegacyComponentMapping, + LegacyComponentMappingPlan, + LessonBlueprint, + LessonSlide, + PresentationContentPlan, + PresentationMediaRequestPlan, + SlideComponent, +) from .presentation_content import content_item_is_complete +CANONICAL_BLUEPRINT_PATH = "presentation/presentation_blueprint.json" +LEGACY_BLUEPRINT_PATH = "blueprints/lesson_blueprint.json" +MAPPING_PATH = "presentation/legacy_component_mapping.json" +PROVENANCE_PATH = "presentation/legacy_blueprint_provenance.json" + + def adapt_canonical_presentation_blueprint( blueprint: CanonicalPresentationBlueprint, content_plan: PresentationContentPlan | None = None, + media_request_plan: PresentationMediaRequestPlan | None = None, + *, + allow_planned_media: bool = False, + include_diagnostic_trace: bool = False, ) -> LessonBlueprint: - """Return a legacy-shaped projection without selecting or changing pedagogy. + """Project canonical units into the existing renderer contract. - Teacher-only units intentionally have no legacy learner slide. The legacy - contract has no safe teacher channel, and this adapter is not a renderer. + This function does not read source material and does not select activities, + evidence, layout, or pedagogy. It only serializes already-approved units + into the legacy shape. Trace is emitted by + :func:`build_legacy_component_mapping`, never embedded in learner payload. """ learner_units = [unit for unit in blueprint.presentation_units if not unit.teacher_channel_reference] content_by_id = {item.id: item for item in (content_plan.content_items if content_plan else [])} + request_by_content = { + request.content_item_id: request + for request in (media_request_plan.requests if media_request_plan else []) + } slides = [ - LessonSlide( - id=index, - slide_type="PracticeSlide", - layout_variant="canonical_shadow", - title=blueprint.lesson_title, - content_blocks=[ - ContentBlock(id=f"unit_{index}_content_{content_index}", text=content) - for content_index, content in enumerate(_display_content(unit, content_by_id.get(unit.content_item_id or "")), start=1) - ], - components=_components_for_unit(index, unit, content_by_id.get(unit.content_item_id or ""), content_plan is not None), + _slide_for_unit( + index, + unit, + content_by_id.get(unit.content_item_id or ""), + request_by_content.get(unit.content_item_id or ""), + content_plan is not None, + allow_planned_media=allow_planned_media, + include_diagnostic_trace=include_diagnostic_trace, ) for index, unit in enumerate(learner_units, start=1) ] - vocabulary = list(dict.fromkeys(content for slide in slides for block in slide.content_blocks for content in [block.text])) + vocabulary = list(dict.fromkeys( + block.text + for slide in slides + for block in slide.content_blocks + if block.text + )) return LessonBlueprint( lesson_title=blueprint.lesson_title, key_vocabulary=[{"word": item} for item in vocabulary], slides=slides, + artifact_role="legacy_compatibility", + canonical_source_artifact=CANONICAL_BLUEPRINT_PATH, + provenance_artifact=PROVENANCE_PATH, + ) + + +def build_legacy_component_mapping( + canonical: CanonicalPresentationBlueprint, + legacy: LessonBlueprint, + content_plan: PresentationContentPlan | None = None, +) -> LegacyComponentMappingPlan: + """Create the non-learner-facing unit/slide/component trace.""" + learner_slides = iter(legacy.slides) + content_by_id = {item.id: item for item in (content_plan.content_items if content_plan else [])} + mappings: list[LegacyComponentMapping] = [] + blocking: list[str] = [] + for unit in canonical.presentation_units: + content_item = content_by_id.get(unit.content_item_id or "") + if unit.teacher_channel_reference: + mappings.append(_mapping_for_unit(unit, content_item, None, None, learner_visible=False)) + continue + slide = next(learner_slides, None) + if slide is None: + blocking.append(f"Canonical presentation unit '{unit.presentation_unit_id}' has no legacy slide mapping.") + mappings.append(_mapping_for_unit(unit, content_item, None, None, learner_visible=True)) + continue + component = slide.components[0] if slide.components else None + mappings.append(_mapping_for_unit(unit, content_item, slide, component, learner_visible=True)) + return LegacyComponentMappingPlan( + state="blocked" if blocking else "pass", + mappings=mappings, + canonical_blueprint_fingerprint=_fingerprint(canonical), + legacy_blueprint_fingerprint=_fingerprint(legacy), + warnings=[], + blocking=blocking, + ) + + +def build_legacy_blueprint_provenance( + canonical: CanonicalPresentationBlueprint, + legacy: LessonBlueprint, + mapping: LegacyComponentMappingPlan, + upstream_artifact_fingerprints: dict[str, str] | None = None, +) -> LegacyBlueprintProvenance: + """Summarize the deterministic adapter contract for audits and staleness.""" + return LegacyBlueprintProvenance( + state=mapping.state, + source_artifacts=[*canonical.source_artifacts, CANONICAL_BLUEPRINT_PATH, MAPPING_PATH], + canonical_blueprint_fingerprint=_fingerprint(canonical), + legacy_blueprint_fingerprint=_fingerprint(legacy), + presentation_unit_count=len(canonical.presentation_units), + legacy_slide_count=len(legacy.slides), + legacy_component_count=sum(len(slide.components) for slide in legacy.slides), + learner_visible_mapping_count=sum(item.learner_visible for item in mapping.mappings), + upstream_artifact_fingerprints=dict(upstream_artifact_fingerprints or {}), + warnings=list(mapping.warnings), + blocking=list(mapping.blocking), + ) + + +def _fingerprint(model) -> str: + payload = json.dumps( + model.model_dump(mode="json"), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _slide_for_unit( + index: int, + unit, + content_item, + media_request, + content_supplied: bool, + *, + allow_planned_media: bool, + include_diagnostic_trace: bool, +) -> LessonSlide: + if content_supplied and ( + content_item is None + or not content_item_is_complete(content_item, allow_planned_audio=allow_planned_media) + ): + components: list[SlideComponent] = [] + else: + components = _components_for_unit( + index, + unit, + content_item, + media_request, + allow_planned_media=allow_planned_media, + include_diagnostic_trace=include_diagnostic_trace, + ) + return LessonSlide( + id=index, + slide_type="PracticeSlide", + layout_variant="canonical_compatibility", + title=unit.presentation_mode.replace("_", " ").title(), + content_blocks=[ + ContentBlock(id=f"unit_{index}_content_{content_index}", text=content) + for content_index, content in enumerate(_display_content(unit, content_item), start=1) + ], + components=components, ) def _display_content(unit, content_item) -> list[str]: if content_item is None: return list(unit.learner_facing_content) - return list(dict.fromkeys(value for value in [content_item.prompt, *content_item.learner_instructions, *content_item.display_items, content_item.learner_safe_hint] if value)) + return list(dict.fromkeys( + value + for value in [ + content_item.prompt, + *content_item.learner_instructions, + *content_item.display_items, + content_item.learner_safe_hint, + ] + if value + )) -def _components_for_unit(index: int, unit, content_item, content_supplied: bool) -> list[SlideComponent]: - if content_supplied and (content_item is None or not content_item_is_complete(content_item)): - return [] - # This is renderer-safe provenance only. It does not add pedagogical authority. - trace = {**unit.trace.model_dump(mode="json"), "content_item_id": content_item.id if content_item else ""} +def _components_for_unit( + index: int, + unit, + content_item, + media_request, + *, + allow_planned_media: bool, + include_diagnostic_trace: bool, +) -> list[SlideComponent]: + def diagnostic_data(data: dict) -> dict: + if include_diagnostic_trace: + return {**data, "_shadow_trace": { + **unit.trace.model_dump(mode="json"), + "content_item_id": content_item.id if content_item else "", + }} + return data + if content_item and content_item.presentation_mode == "listening_choice": - audio = next((item for item in content_item.audio_asset_refs if item.availability == "available"), None) + audio = next( + ( + item for item in content_item.audio_asset_refs + if item.availability == "available" + or (allow_planned_media and item.availability == "planned") + ), + None, + ) if audio and content_item.options and content_item.accepted_responses: return [SlideComponent( id=f"unit_{index}_listen", component_type="ListenAndChoose", - data={ + data=diagnostic_data({ "choices": [option.text for option in content_item.options], "answer": content_item.accepted_responses[0].normalized_value, "audio_key": audio.asset_id, - "_shadow_trace": trace, - }, + "audio_text": content_item.accepted_responses[0].normalized_value, + }), )] return [] if content_item and content_item.presentation_mode == "matching_response": @@ -69,20 +234,32 @@ def _components_for_unit(index: int, unit, content_item, content_supplied: bool) return [SlideComponent( id=f"unit_{index}_match", component_type="MatchGame", - data={ + data=diagnostic_data({ "pairs": [{"left": pair.left, "right": pair.right} for pair in content_item.matching_pairs], - "_shadow_trace": trace, - }, + }), )] return [] display_items = content_item.display_items if content_item else unit.learner_facing_content if content_item and content_item.presentation_mode == "choice_response": display_items = [option.text for option in content_item.options] return [SlideComponent( - id=f"unit_{index}_trace", + id=f"unit_{index}_content", component_type="VocabularyFlipCard", - data={ - "items": [{"word": content} for content in display_items], - "_shadow_trace": trace, - }, + data=diagnostic_data({"items": [{"word": content} for content in display_items]}), )] + + +def _mapping_for_unit(unit, content_item, slide, component, *, learner_visible: bool) -> LegacyComponentMapping: + return LegacyComponentMapping( + mapping_id=f"map_{unit.presentation_unit_id}", + presentation_unit_id=unit.presentation_unit_id, + binding_id=unit.binding_id, + activity_id=unit.activity_id, + evidence_ids=list(unit.evidence_ids), + content_item_id=content_item.id if content_item else None, + legacy_slide_id=slide.id if slide else None, + legacy_component_id=component.id if component else None, + structural_role=unit.unit_role, + learner_visible=learner_visible, + trace=unit.trace, + ) diff --git a/apps/api/src/hcs_api/presentation_adapter_assessment.py b/apps/api/src/hcs_api/presentation_adapter_assessment.py index e2ff3e0..fea1910 100644 --- a/apps/api/src/hcs_api/presentation_adapter_assessment.py +++ b/apps/api/src/hcs_api/presentation_adapter_assessment.py @@ -7,7 +7,7 @@ from pydantic import ValidationError -from .blueprint_compatibility import adapt_canonical_presentation_blueprint +from .blueprint_compatibility import adapt_canonical_presentation_blueprint, build_legacy_component_mapping from .components import load_component_registry from .presentation_content import content_item_is_complete from .models import ( @@ -74,8 +74,9 @@ def run_presentation_adapter_assessment( _increment(report, capability.mapping_quality, count) _assess_capability(report, capability, canonical, content_plan) - _check_trace_coverage(report, canonical, adapted) - _check_teacher_safety(report, canonical, adapted) + mapping = build_legacy_component_mapping(canonical, adapted, content_plan) + _check_trace_coverage(report, canonical, adapted, mapping) + _check_teacher_safety(report, canonical, adapted, mapping) _warn(report, "Visual parity is not checked by this render-input capability assessment.") report.notes.extend([ "Assessment uses registry-required field checks only; component quality rules are not a centralized schema.", @@ -228,6 +229,7 @@ def _check_trace_coverage( report: PresentationAdapterAssessmentReport, canonical: CanonicalPresentationBlueprint, adapted: LessonBlueprint, + mapping=None, ) -> None: expected = { unit.presentation_unit_id @@ -235,12 +237,18 @@ def _check_trace_coverage( if unit.render_ready and "learner_interaction" in unit.learner_channel } traces = { - trace.get("presentation_unit_id") - for slide in adapted.slides - for component in slide.components - for trace in [component.data.get("_shadow_trace")] - if isinstance(trace, dict) + item.presentation_unit_id + for item in (mapping.mappings if mapping else []) + if item.learner_visible and item.legacy_component_id } + if not traces: + traces = { + trace.get("presentation_unit_id") + for slide in adapted.slides + for component in slide.components + for trace in [component.data.get("_shadow_trace")] + if isinstance(trace, dict) + } report.trace_coverage = len(expected & traces) / len(expected) if expected else 1.0 for unit_id in sorted(expected - traces): _block(report, f"Interactive unit '{unit_id}' loses trace metadata in the adapted legacy input.") @@ -250,6 +258,7 @@ def _check_teacher_safety( report: PresentationAdapterAssessmentReport, canonical: CanonicalPresentationBlueprint, adapted: LessonBlueprint, + mapping=None, ) -> None: teacher_units = { unit.presentation_unit_id @@ -262,6 +271,11 @@ def _check_teacher_safety( for marker in TEACHER_TEXT_MARKERS: if marker in serialized: report.teacher_channel_findings.append(f"Adapted learner output contains teacher-only marker '{marker}'.") + for item in (mapping.mappings if mapping else []): + if item.presentation_unit_id in teacher_units and item.learner_visible: + report.teacher_channel_findings.append( + f"Teacher-only unit '{item.presentation_unit_id}' is mapped to a learner-facing component." + ) for slide in adapted.slides: for component in slide.components: trace = component.data.get("_shadow_trace") diff --git a/apps/api/src/hcs_api/presentation_bindings.py b/apps/api/src/hcs_api/presentation_bindings.py index b674751..ed6621d 100644 --- a/apps/api/src/hcs_api/presentation_bindings.py +++ b/apps/api/src/hcs_api/presentation_bindings.py @@ -94,7 +94,7 @@ def check_activity_bindings( bindings_by_target.setdefault(key, []).append(binding) for (slide_id, component_id, mode), bindings in bindings_by_target.items(): - if len(bindings) > 1: + if len(bindings) > 1 and len({binding.activity_id for binding in bindings}) > 1: evs = ", ".join(sorted({b.evidence_id for b in bindings})) report.blocking.append( f"Duplicate presentation target binding: slide_id={slide_id} component_id={component_id} mode={mode} " @@ -110,24 +110,31 @@ def check_activity_bindings( report.blocking.append(f"Binding '{binding.binding_id}' references unknown activity '{binding.activity_id}'") if binding.evidence_id not in evidence_ids: report.blocking.append(f"Binding '{binding.binding_id}' references unknown evidence '{binding.evidence_id}'") + activity = activities.get(binding.activity_id) + ev = evidence.get(binding.evidence_id) + teacher_target = bool( + activity and not activity.learner_facing + or ev and ( + ev.collection_method == "teacher_observation" + or ev.evidence_type == "teacher_observation" + ) + ) slide = slides.get(binding.slide_id) - if not slide: + if not slide and not (binding.slide_id == 0 and teacher_target): report.blocking.append(f"Binding '{binding.binding_id}' references unknown slide '{binding.slide_id}'") continue component = None - if binding.component_id: + if slide and binding.component_id: component = next((c for c in slide.components if c.id == binding.component_id), None) if not component: report.blocking.append( f"Binding '{binding.binding_id}' references unknown component '{binding.component_id}' on slide '{binding.slide_id}'" ) - activity = activities.get(binding.activity_id) if _is_zero_beginner(learner_level): component_type = component.component_type if component else "" activity_type = activity.activity_type if activity else "" if component_type in UNSUITABLE_ZB_COMPONENTS or activity_type in {"open_response", "role_play_scene", "drag_sentence"}: report.blocking.append(f"Binding '{binding.binding_id}' points zero_beginner evidence to unsuitable activity/component") - ev = evidence.get(binding.evidence_id) if ev and ev.evidence_type == "teacher_observation": modes = set(binding.presentation_modes) if not ({"speaker_notes", "teacher_observation"} & modes): diff --git a/apps/api/src/hcs_api/presentation_readiness.py b/apps/api/src/hcs_api/presentation_readiness.py index 0773039..cdf95cb 100644 --- a/apps/api/src/hcs_api/presentation_readiness.py +++ b/apps/api/src/hcs_api/presentation_readiness.py @@ -79,10 +79,15 @@ def check_presentation_readiness( continue if binding_strategy == "legacy_resolved": slide = slides.get(binding.slide_id) - if not slide: + teacher_target = ( + not activity.learner_facing + or evidence_spec.collection_method == "teacher_observation" + or evidence_spec.evidence_type == "teacher_observation" + ) + if not slide and not (binding.slide_id == 0 and teacher_target): _invalid(report, f"Binding '{binding.binding_id}' references unknown slide '{binding.slide_id}'.") continue - if binding.component_id and not any(item.id == binding.component_id for item in slide.components): + if slide and binding.component_id and not any(item.id == binding.component_id for item in slide.components): _invalid( report, f"Binding '{binding.binding_id}' references unknown component '{binding.component_id}' on slide '{binding.slide_id}'.", diff --git a/apps/api/src/hcs_api/quality.py b/apps/api/src/hcs_api/quality.py index 4c07c9f..901769a 100644 --- a/apps/api/src/hcs_api/quality.py +++ b/apps/api/src/hcs_api/quality.py @@ -24,10 +24,12 @@ def check_quality(project_root: Path, blueprint: LessonBlueprint, manifest: Asse report.missing_titles.append("课程缺少标题") else: report.passed.append("lesson_has_title") - if not blueprint.objectives: + if not blueprint.objectives and blueprint.artifact_role not in {"legacy_compatibility", "legacy_diagnostic"}: _block(report, "课程缺少学习目标") - else: + elif blueprint.objectives: report.passed.append("lesson_has_objectives") + else: + report.passed.append("learning_goals_verified_by_state_evidence_kernel") if not blueprint.slides: _block(report, "课程缺少页面") else: diff --git a/apps/api/src/hcs_api/renderer.py b/apps/api/src/hcs_api/renderer.py index 1b4386d..82bab6c 100644 --- a/apps/api/src/hcs_api/renderer.py +++ b/apps/api/src/hcs_api/renderer.py @@ -28,7 +28,7 @@ def render_lesson( is_classroom = render_mode == "classroom" filename = output_filename or ("lesson_classroom.html" if is_classroom else "lesson.html") body_class = ' class="v2-internal"' if filename == "lesson_v2_internal.html" else "" - data_blob = _build_lesson_data_blob(profile, blueprint, report, is_classroom, activity_bindings) + data_blob = _build_lesson_data_blob(profile, blueprint, report, is_classroom, activity_bindings, render_mode) title_label = escape(profile.scaffolding_language) if not is_classroom else "辅助语言" html = f""" @@ -111,15 +111,28 @@ def _build_lesson_data_blob( report: QualityReport, is_classroom: bool, activity_bindings: PresentationBindingPlan | None = None, + render_mode: str = "debug", ) -> str: if not is_classroom: + blueprint_payload = blueprint.model_dump(mode="json") + if render_mode != "diagnostic": + blueprint_payload.pop("artifact_role", None) + blueprint_payload.pop("canonical_source_artifact", None) + blueprint_payload.pop("provenance_artifact", None) + for slide in blueprint_payload.get("slides", []): + for component in slide.get("components", []): + data = component.get("data") + if isinstance(data, dict): + for key in ( + "_shadow_trace", "presentation_unit_id", "content_item_id", + "binding_id", "activity_id", "evidence_id", "evidence_ids", + ): + data.pop(key, None) return json.dumps( - {"profile": profile.model_dump(mode="json"), "blueprint": blueprint.model_dump(mode="json"), "quality": report.model_dump(mode="json")}, + {"profile": profile.model_dump(mode="json"), "blueprint": blueprint_payload, "quality": report.model_dump(mode="json")}, ensure_ascii=False, ).replace(" str: if is_classroom and PROVIDER_REQUIRED.search(text): @@ -332,7 +350,7 @@ def _scaffold_text(text: str) -> str: def _shadow_trace_attrs(data: dict) -> str: - """Expose only existing v2 trace IDs as inert DOM metadata for diagnostics.""" + """Expose existing v2 trace IDs only for the explicit diagnostic renderer.""" trace = data.get("_shadow_trace") if not isinstance(trace, dict): return "" From c1e6970ac9910b25e5ac4087bbd2d28b987ca671 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:28 +0700 Subject: [PATCH 04/16] fix: prevent blueprint endpoint from bypassing teaching kernel --- apps/api/src/hcs_api/agent.py | 31 ++++-- apps/api/src/hcs_api/main.py | 176 +++++++++++++++++++++++++++++----- 2 files changed, 177 insertions(+), 30 deletions(-) diff --git a/apps/api/src/hcs_api/agent.py b/apps/api/src/hcs_api/agent.py index d9764b4..db79a03 100644 --- a/apps/api/src/hcs_api/agent.py +++ b/apps/api/src/hcs_api/agent.py @@ -43,6 +43,20 @@ def validate_agent_output(project_id: str) -> AgentValidation: required = [ "specs/lesson_spec.md", "specs/spec_lock.json", + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/presentation_blueprint.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_readiness_report.json", + "presentation/activity_bindings.json", "blueprints/lesson_blueprint.json", "blueprints/interaction_plan.json", "blueprints/media_plan.json", @@ -100,14 +114,14 @@ def _build_task_text(project_id: str, route: str, mode: str) -> str: - `specs/lesson_spec.md` - `specs/spec_lock.json` -- `blueprints/lesson_blueprint.json` -- `blueprints/interaction_plan.json` -- `blueprints/media_plan.json` -- `assets/data/asset_manifest.json` only when media references change +- `learning/learning_state_plan.json` +- `learning/evidence_plan.json` +- `learning/activity_plan.json` -`blueprints/lesson_blueprint.json` is a legacy presentation contract. Agents may edit display-safe -presentation structure, but must not add or redefine learning goals, evidence specs, learning -activities, learner-state assumptions, teacher-only evidence rules, or quality judgments there. +`blueprints/lesson_blueprint.json` is a read-only legacy renderer compatibility artifact. It is +created only by the canonical presentation adapter. Do not edit it, `presentation/presentation_blueprint.json`, +media plans, rendered HTML, or exports. Revise the State-Evidence artifacts above, then ask +HanClassStudio to regenerate canonical presentation and compatibility outputs. After editing, ask HanClassStudio to validate agent output, then render, run the quality gate, and export only if quality allows it. """ @@ -123,7 +137,8 @@ def _build_rules_text() -> str: - Chinese is always the target language. - The scaffolding language supports comprehension only; it must not replace Chinese input or output. - Do not bypass the quality gate. -- Treat `blueprints/lesson_blueprint.json` as presentation-only compatibility output, never pedagogical truth. +- Treat `blueprints/lesson_blueprint.json` as read-only presentation compatibility output, never pedagogical truth. +- Never edit or regenerate the legacy Blueprint directly; use the State-Evidence blueprint stage. - Do not add learning goals, evidence specs, activity selection policy, or teacher-only notes to learner-facing blueprint data. """ diff --git a/apps/api/src/hcs_api/main.py b/apps/api/src/hcs_api/main.py index 719e1b5..6ad007f 100644 --- a/apps/api/src/hcs_api/main.py +++ b/apps/api/src/hcs_api/main.py @@ -36,11 +36,13 @@ AssetManifest, ArtifactTree, AudioProviderSettings, + CanonicalPresentationBlueprint, EditablePptxExportResponse, ImageProviderSettings, LLMProviderSettings, LessonBlueprint, LessonProfile, + LegacyBlueprintProvenance, MediaReviewAction, OCRProviderSettings, ProjectState, @@ -125,11 +127,12 @@ test_online_connection, stop_comfyui_runtime_package, ) -from .pipeline import generate_lesson_blueprint, generate_project_media -from .pipeline import render_and_check, run_full_pipeline, write_blueprint_artifacts, write_spec_artifacts +from .pipeline import generate_project_media, production_blueprint_stage_is_current +from .pipeline import render_and_check, run_blueprint_stage, run_full_pipeline, write_blueprint_artifacts, write_spec_artifacts from .pptx_exporter import export_editable_pptx from .storage import ( PROJECTS_DIR, + artifact_fingerprint, bump_project_revision, project_revision, clear_stale_state, @@ -1736,25 +1739,39 @@ def generate_blueprint(project_id: str, expected_revision: int | None = Query(de profile = read_model(project_id, "lesson_profile.json", LessonProfile) if not source or not profile: raise HTTPException(status_code=400, detail="Project needs source material and lesson profile") - _assert_llm_provider_supported(read_provider_settings()) - write_spec_artifacts(project_id, source, profile) + settings = read_provider_settings() + _assert_llm_provider_supported(settings) + _assert_production_llm_migrated(settings) + if production_blueprint_stage_is_current(project_id): + return get_project_state(project_id) try: - blueprint, _ = generate_lesson_blueprint(source, profile, read_provider_settings(), project_id=project_id) - except CodexBridgeActionRequired as exc: - raise _codex_action_required(exc) from exc + state = run_blueprint_stage(project_id, settings) except ProviderError as exc: raise HTTPException( - status_code=502, + status_code=409, detail={ - "code": "provider_execution_failed", + "code": "llm_production_contract_unsupported", "capability": "llm", - "provider_id": read_provider_settings().llm.provider, + "provider_id": settings.llm.provider, "message": str(exc), }, ) from exc - write_blueprint_artifacts(project_id, blueprint) - clear_stale_state(project_id, stages={"profile", "design", "presentation"}) - invalidate_downstream(project_id, "blueprint", "Blueprint changed; media, render, quality, and export are stale.") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + report = read_json(project_id, "quality/presentation_revision_plan.json") or {} + if not state.lesson_blueprint or report.get("state") == "blocked": + raise HTTPException( + status_code=409, + detail={ + "code": "presentation_stage_blocked", + "message": report.get("message", "Canonical presentation production is blocked."), + "blocking_reasons": report.get("blocking_issues", state.gate_summary.blocking_reasons), + }, + ) + # Blueprint-stage output is current through the canonical presentation and + # adapter. Only renderer-facing downstream work remains stale. + clear_stale_state(project_id, stages={"design", "presentation"}) + invalidate_downstream(project_id, "blueprint", "Canonical presentation changed; media, render, quality, and export are stale.") bump_project_revision(project_id) return get_project_state(project_id) @@ -1763,12 +1780,14 @@ def generate_blueprint(project_id: str, expected_revision: int | None = Query(de def save_blueprint(project_id: str, blueprint: LessonBlueprint, expected_revision: int | None = Query(default=None)) -> ProjectState: _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) - _assert_upstream_current(project_id, blocked_stages={"profile"}, action="save blueprint") - write_blueprint_artifacts(project_id, blueprint) - clear_stale_state(project_id, stages={"profile", "design", "presentation"}) - invalidate_downstream(project_id, "blueprint", "Blueprint changed; media, render, quality, and export are stale.") - bump_project_revision(project_id) - return get_project_state(project_id) + raise HTTPException( + status_code=409, + detail={ + "code": "legacy_blueprint_read_only", + "message": "Legacy LessonBlueprint is a compatibility artifact. Edit the State-Evidence upstream artifacts and regenerate presentation.", + "canonical_artifact": "presentation/presentation_blueprint.json", + }, + ) @app.post("/api/projects/{project_id}/media", response_model=ProjectState) @@ -1776,6 +1795,7 @@ def generate_media(project_id: str, force_regenerate: bool = Query(False), expec root = _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) _assert_upstream_current(project_id, blocked_stages={"profile", "design", "presentation"}, action="generate media") + _assert_canonical_presentation_current(project_id, action="generate media") blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) if not blueprint: raise HTTPException(status_code=400, detail="Generate a lesson blueprint first") @@ -1875,6 +1895,7 @@ def review_media(project_id: str, asset_id: str, action: MediaReviewAction, expe root = _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) _assert_upstream_current(project_id, blocked_stages={"profile", "design", "presentation"}, action="review media") + _assert_canonical_presentation_current(project_id, action="review media") manifest = read_model(project_id, "asset_manifest.json", AssetManifest) if not manifest: raise HTTPException(status_code=404, detail="Asset manifest not found") @@ -1896,6 +1917,7 @@ async def replace_media( root = _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) _assert_upstream_current(project_id, blocked_stages={"profile", "design", "presentation"}, action="replace media") + _assert_canonical_presentation_current(project_id, action="replace media") manifest = read_model(project_id, "asset_manifest.json", AssetManifest) if not manifest: raise HTTPException(status_code=404, detail="Asset manifest not found") @@ -1917,6 +1939,7 @@ def render_project(project_id: str, expected_revision: int | None = Query(defaul root = _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) _assert_upstream_current(project_id, blocked_stages={"profile", "design", "presentation"}, action="render") + _assert_canonical_presentation_current(project_id, action="render") profile = read_model(project_id, "lesson_profile.json", LessonProfile) blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) manifest = read_model(project_id, "asset_manifest.json", AssetManifest) @@ -1987,9 +2010,11 @@ def run_project_pipeline(project_id: str, expected_revision: int | None = Query( root = _assert_project(project_id) _assert_expected_revision(project_id, expected_revision) try: - _assert_llm_provider_supported(read_provider_settings()) - _assert_media_provider_ready(read_provider_settings()) - run_full_pipeline(project_id, root, read_provider_settings()) + settings = read_provider_settings() + _assert_llm_provider_supported(settings) + _assert_production_llm_migrated(settings) + _assert_media_provider_ready(settings) + run_full_pipeline(project_id, root, settings) gate_paths = ( "quality/evidence_alignment_report.json", "quality/presentation_readiness_report.json", @@ -2145,6 +2170,14 @@ def _technical_export_reason(project_id: str, state: ProjectState) -> str | None """Return a blocker that a force flag is never allowed to bypass.""" if not state.artifacts.get("lesson_blueprint"): return "Blueprint artifact is missing; export cannot proceed" + root = PROJECTS_DIR / project_id + for path, label in ( + ("presentation/presentation_blueprint.json", "Canonical presentation blueprint"), + ("presentation/legacy_component_mapping.json", "Legacy component mapping"), + ("presentation/legacy_blueprint_provenance.json", "Legacy blueprint provenance"), + ): + if not (root / path).is_file(): + return f"{label} artifact is missing; export cannot proceed" lesson_path = PROJECTS_DIR / project_id / "courseware" / "lesson.html" if (reason := _render_artifact_reason(lesson_path)) is not None: return f"{reason}; export cannot proceed" @@ -2196,6 +2229,87 @@ def _assert_upstream_current(project_id: str, *, blocked_stages: set[str], actio ) +def _assert_canonical_presentation_current(project_id: str, *, action: str) -> None: + """Guard every renderer-facing route against a legacy-only project state.""" + required = ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.json", + "presentation/presentation_media_request_plan.json", + "presentation/abstract_activity_bindings.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + ) + missing = [path for path in required if read_json(project_id, path) is None] + blocked_reports = [] + for path in ( + "quality/evidence_alignment_report.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + "quality/presentation_revision_plan.json", + ): + payload = read_json(project_id, path) + if isinstance(payload, dict) and payload.get("state") == "blocked": + blocked_reports.append(path) + consistency_findings = _canonical_consistency_findings(project_id) + if not missing and not blocked_reports and not consistency_findings: + return + reasons = [f"Missing canonical production artifact: {path}" for path in missing] + reasons.extend(f"Canonical production gate is blocked: {path}" for path in blocked_reports) + reasons.extend(consistency_findings) + raise HTTPException( + status_code=409, + detail={ + "code": "canonical_presentation_required", + "action": action, + "blocking_reasons": reasons, + "message": "Run the State-Evidence blueprint stage before using renderer-facing actions.", + }, + ) + + +def _canonical_consistency_findings(project_id: str) -> list[str]: + """Detect hand-edited compatibility output before renderer-facing work.""" + from .blueprint_compatibility import _fingerprint + provenance_payload = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + canonical_payload = read_json(project_id, "presentation/presentation_blueprint.json") + if not isinstance(provenance_payload, dict): + return ["Legacy blueprint provenance is missing or invalid."] + try: + provenance = LegacyBlueprintProvenance.model_validate(provenance_payload) + except Exception as exc: + return [f"Legacy blueprint provenance is invalid: {exc}"] + findings: list[str] = [] + if not isinstance(canonical_payload, dict): + findings.append("Canonical presentation blueprint is missing or invalid.") + else: + try: + canonical = CanonicalPresentationBlueprint.model_validate(canonical_payload) + except Exception as exc: + findings.append(f"Canonical presentation blueprint is invalid: {exc}") + else: + if provenance.canonical_blueprint_fingerprint and _fingerprint(canonical) != provenance.canonical_blueprint_fingerprint: + findings.append("Canonical presentation Blueprint changed after compilation; rerun presentation compilation.") + if blueprint is None: + findings.append("Legacy compatibility Blueprint is missing or invalid.") + if provenance.artifact_role != "legacy_compatibility": + findings.append("Legacy blueprint is not marked as a compatibility artifact.") + if blueprint is not None and provenance.legacy_blueprint_fingerprint and _fingerprint(blueprint) != provenance.legacy_blueprint_fingerprint: + findings.append("Legacy compatibility Blueprint changed after the canonical adapter; rerun presentation compilation.") + for path, expected in provenance.upstream_artifact_fingerprints.items(): + if artifact_fingerprint(project_id, path) != expected: + findings.append(f"Upstream artifact changed after canonical compilation: {path}.") + return findings + + def _assert_media_provider_ready(settings: ProviderSettings) -> None: selected = {"image": settings.image.provider, "tts": settings.audio.provider} catalog = provider_capability_catalog(settings) @@ -2242,6 +2356,24 @@ def _assert_llm_provider_supported(settings: ProviderSettings) -> None: ) +def _assert_production_llm_migrated(settings: ProviderSettings) -> None: + """Prevent an unmigrated complete-Slides provider from becoming fallback truth.""" + if settings.llm.provider == "deterministic": + return + raise HTTPException( + status_code=409, + detail={ + "code": "llm_production_contract_unsupported", + "capability": "llm", + "provider_id": settings.llm.provider, + "message": ( + "This LLM provider still implements the legacy complete LessonBlueprint contract. " + "It is not available for State-Evidence production until it generates upstream analysis artifacts." + ), + }, + ) + + def _binding_gate_blocked(project_id: str) -> bool: report = read_json(project_id, "presentation/binding_quality_report.json") or {} return isinstance(report, dict) and report.get("state") == "blocked" From 643fd409e431056f3fa1707716dd28a51a233296 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:37 +0700 Subject: [PATCH 05/16] refactor: move llm generation upstream from slide production --- apps/api/src/hcs_api/agents.py | 15 ++++++-- apps/api/src/hcs_api/providers.py | 59 ++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/apps/api/src/hcs_api/agents.py b/apps/api/src/hcs_api/agents.py index 9ae69b2..74e0433 100644 --- a/apps/api/src/hcs_api/agents.py +++ b/apps/api/src/hcs_api/agents.py @@ -144,13 +144,13 @@ def _estimate_duration(source: SourceMaterial) -> str: return f"{estimated} minutes" -def build_blueprint( +def build_legacy_diagnostic_blueprint( source: SourceMaterial, profile: LessonProfile, candidates: TeachingCandidates | None = None, language_items: list | None = None, ) -> LessonBlueprint: - """Build lesson blueprint from source material and optional teaching candidates.""" + """Build a direct Source-to-Slides fixture for diagnostics and regression only.""" from .analysis import extract_candidates if candidates is None: @@ -207,9 +207,20 @@ def build_blueprint( grammar_points=grammar_points, slides=slides, route_hint=route, + artifact_role="legacy_diagnostic", ) +def build_blueprint( + source: SourceMaterial, + profile: LessonProfile, + candidates: TeachingCandidates | None = None, + language_items: list | None = None, +) -> LessonBlueprint: + """Compatibility alias for the explicit legacy diagnostic builder.""" + return build_legacy_diagnostic_blueprint(source, profile, candidates, language_items) + + # ── Objective building ── def _build_objectives(topic: str, route: RouteHint, profile: LessonProfile) -> list[str]: diff --git a/apps/api/src/hcs_api/providers.py b/apps/api/src/hcs_api/providers.py index 910be7f..2ad476f 100644 --- a/apps/api/src/hcs_api/providers.py +++ b/apps/api/src/hcs_api/providers.py @@ -44,7 +44,7 @@ def _provider_definitions() -> list[dict[str, Any]]: return [ { "capability": "llm", "provider_id": "deterministic", "display_name": "Deterministic offline", - "category": "local", "description": "Offline-safe deterministic Blueprint generator", + "category": "local", "description": "Offline-safe State-Evidence production compiler", "fields": [], "operations": ["blueprint"], "repository_url": hcs_repository, "code_license_name": "MIT", "code_license_url": hcs_license, }, @@ -54,7 +54,9 @@ def _provider_definitions() -> list[dict[str, Any]]: "fields": [_field("base_url", "Base URL", "url", required=True, placeholder="https://api.openai.com/v1"), _field("api_key", "API key", "password", required=True), _field("model", "Model", required=True)], - "operations": ["blueprint", "illustration"], + "operations": ["legacy_diagnostic_blueprint", "illustration"], + "production_ready": False, + "production_unavailable_reason": "Legacy complete-Slides provider is not migrated to the State-Evidence production contract.", "official_homepage_url": "https://openai.com/api/", "api_docs_url": openai_docs, "api_signup_url": openai_signup, "terms_url": openai_terms, "privacy_url": openai_privacy, }, @@ -63,7 +65,9 @@ def _provider_definitions() -> list[dict[str, Any]]: "category": "local", "description": "Local Ollama chat endpoint", "fields": [_field("base_url", "Base URL", "url", placeholder="http://127.0.0.1:11434"), _field("model", "Model", required=True)], - "operations": ["blueprint", "illustration"], + "operations": ["legacy_diagnostic_blueprint", "illustration"], + "production_ready": False, + "production_unavailable_reason": "Legacy complete-Slides provider is not migrated to the State-Evidence production contract.", "repository_url": "https://github.com/ollama/ollama", "code_license_name": "MIT", "code_license_url": "https://github.com/ollama/ollama/blob/main/LICENSE", }, @@ -72,7 +76,9 @@ def _provider_definitions() -> list[dict[str, Any]]: "category": "local", "description": "Local OpenAI-compatible endpoint", "fields": [_field("base_url", "Base URL", "url", placeholder="http://127.0.0.1:1234/v1"), _field("model", "Model", required=True)], - "operations": ["blueprint", "illustration"], + "operations": ["legacy_diagnostic_blueprint", "illustration"], + "production_ready": False, + "production_unavailable_reason": "Legacy complete-Slides provider is not migrated to the State-Evidence production contract.", "official_homepage_url": "https://lmstudio.ai/", "terms_url": "https://lmstudio.ai/app-terms", "privacy_url": "https://lmstudio.ai/app-privacy", }, @@ -82,14 +88,18 @@ def _provider_definitions() -> list[dict[str, Any]]: "fields": [_field("base_url", "Base URL", "url", required=True), _field("api_key", "API key", "password", required=True), _field("model", "Model", required=True)], - "operations": ["blueprint", "illustration"], + "operations": ["legacy_diagnostic_blueprint", "illustration"], + "production_ready": False, + "production_unavailable_reason": "Legacy complete-Slides provider is not migrated to the State-Evidence production contract.", }, { "capability": "llm", "provider_id": "codex_chatgpt", "display_name": "Codex ChatGPT Bridge", "category": "local", "description": "Audited asynchronous handoff to a live Codex agent session", "fields": [_field("api_key", "Bridge token", "password", required=True), _field("model", "Model label", placeholder="codex-chatgpt")], - "operations": ["blueprint", "illustration"], + "operations": ["legacy_diagnostic_blueprint", "illustration"], + "production_ready": False, + "production_unavailable_reason": "Legacy complete-Slides provider is not migrated to the State-Evidence production contract.", "repository_url": hcs_repository, "code_license_name": "MIT", "code_license_url": hcs_license, }, { @@ -246,7 +256,10 @@ def provider_capability_catalog(settings: ProviderSettings) -> list[ProviderCapa capability=item["capability"], provider_id=item["provider_id"], display_name=item["display_name"], category=item["category"], description=item["description"], implemented=implemented, configurable=item.get("configurable", implemented), configured=configured, available=available, - experimental=item.get("experimental", False), unavailable_reason=reason, + experimental=item.get("experimental", False), + production_ready=item.get("production_ready", True), + production_unavailable_reason=item.get("production_unavailable_reason"), + unavailable_reason=reason, official_homepage_url=item.get("official_homepage_url"), api_signup_url=item.get("api_signup_url"), api_docs_url=item.get("api_docs_url"), repository_url=item.get("repository_url"), model_card_url=item.get("model_card_url"), @@ -354,12 +367,13 @@ def provider_capability_catalog(settings: ProviderSettings) -> list[ProviderCapa return result -def generate_blueprint_with_llm( +def generate_legacy_diagnostic_blueprint_with_llm( source: SourceMaterial, profile: LessonProfile, settings: LLMProviderSettings, project_id: str | None = None, ) -> LessonBlueprint | None: + """Legacy/diagnostic provider seam; never used by production presentation.""" if not _llm_enabled(settings): return None @@ -367,11 +381,11 @@ def generate_blueprint_with_llm( { "role": "system", "content": ( - "You design interactive HTML courseware for international Chinese teachers. " - "Return only valid JSON matching the requested schema." + "You provide a legacy diagnostic LessonBlueprint fixture for regression comparison. " + "This output is not an authoritative production presentation. Return only valid JSON." ), }, - {"role": "user", "content": _blueprint_prompt(source, profile)}, + {"role": "user", "content": _legacy_diagnostic_blueprint_prompt(source, profile)}, ] if settings.provider == "codex_chatgpt": if not project_id: @@ -400,6 +414,16 @@ def generate_blueprint_with_llm( return _normalize_blueprint(blueprint, profile) +def generate_blueprint_with_llm( + source: SourceMaterial, + profile: LessonProfile, + settings: LLMProviderSettings, + project_id: str | None = None, +) -> LessonBlueprint | None: + """Compatibility alias for explicit legacy diagnostic callers.""" + return generate_legacy_diagnostic_blueprint_with_llm(source, profile, settings, project_id) + + def generate_openai_image(settings: ImageProviderSettings, prompt: str) -> bytes | None: if settings.provider != "openai_images" or not settings.api_key or not prompt.strip(): return None @@ -480,9 +504,10 @@ def _chat_completion(settings: LLMProviderSettings, messages: list[dict[str, str return content -def _blueprint_prompt(source: SourceMaterial, profile: LessonProfile) -> str: +def _legacy_diagnostic_blueprint_prompt(source: SourceMaterial, profile: LessonProfile) -> str: return f""" -Create a complete LessonBlueprint JSON object for HanClassStudio. +Create a complete legacy LessonBlueprint JSON object for HanClassStudio diagnostic comparison. +This output must not be used as production teaching or presentation authority. Required JSON shape: {{ @@ -534,6 +559,11 @@ def _blueprint_prompt(source: SourceMaterial, profile: LessonProfile) -> str: """.strip() +def _blueprint_prompt(source: SourceMaterial, profile: LessonProfile) -> str: + """Compatibility alias for legacy prompt fixtures.""" + return _legacy_diagnostic_blueprint_prompt(source, profile) + + def _source_excerpt(source: SourceMaterial, limit: int = 7000) -> str: chunks: list[str] = [f"File: {source.original_filename}", f"Type: {source.source_type}"] for page in source.pages: @@ -548,6 +578,9 @@ def _source_excerpt(source: SourceMaterial, limit: int = 7000) -> str: def _normalize_blueprint(blueprint: LessonBlueprint, profile: LessonProfile) -> LessonBlueprint: + blueprint.artifact_role = "legacy_diagnostic" + blueprint.canonical_source_artifact = "" + blueprint.provenance_artifact = "" blueprint.lesson_title = blueprint.lesson_title.strip() or profile.lesson_title for index, slide in enumerate(blueprint.slides, start=1): slide.id = index From e2d1d959a48e23cc937574d5bda7f762b2700940 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:45 +0700 Subject: [PATCH 06/16] fix: make legacy blueprint read-only in web UI --- apps/web/src/App.tsx | 32 +++++++++++++------------------- apps/web/src/api.ts | 9 --------- apps/web/src/i18n.tsx | 18 ++++++++++++------ apps/web/src/state.ts | 3 +-- apps/web/src/types.ts | 2 ++ 5 files changed, 28 insertions(+), 36 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 678ccea..ff97c63 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -67,7 +67,6 @@ import { renderProject, reviewMedia, runPipeline, - saveBlueprint, saveProfile, uploadProject, validateAgentOutput @@ -1144,7 +1143,7 @@ export function App() { item.stage_id === "presentation")} />

{t("presentation.compatibility")}

{blueprint ? ( - + ) : ( )} @@ -1157,28 +1156,15 @@ export function App() {