diff --git a/README.en.md b/README.en.md index 10c7a8e..0326f8a 100644 --- a/README.en.md +++ b/README.en.md @@ -29,8 +29,9 @@ The core philosophy is **State-first, Evidence-first**: instead of asking "what ``` Phase 2B: substantially complete Phase 2C: internal technical validation complete +State–Evidence production cutover phase 1: complete Real teaching validation: not started -Production v2 cutover: not started +LLM upstream migration: pending ``` The codebase now has a complete production pipeline — from material upload and AI analysis to teaching kernel generation, courseware review, auto-revision, dual HTML/PPTX export, and quality gates. diff --git a/README.md b/README.md index 1611e4c..53681f5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **AI 辅助语言教学课件生成器 — 面向国际中文教育** -[![测试](https://img.shields.io/badge/tests-498%20passed-brightgreen)](#) +[![测试](https://img.shields.io/badge/tests-652%20passed-brightgreen)](#) [![阶段](https://img.shields.io/badge/phase-2C%20内部验证-yellow)](#) [![许可](https://img.shields.io/badge/license-MIT-blue)](#) @@ -29,11 +29,12 @@ HanClassStudio 是一个开源的 AI 辅助互动课件生成系统,专为** ``` Phase 2B:基本完成 Phase 2C:内部技术验证完成 +State–Evidence 生产 cutover 第一阶段:完成 真实教学验证:尚未开始 -生产 v2 cutover:尚未开始 +LLM 上游迁移:待完成 ``` -当前代码库已具备完整的功能流水线:从教材上传、AI 分析、教学内核生成、课件审校、自动修订,到 HTML/PPTX 双端导出和质量门禁。 +当前代码库已具备完整的功能流水线:从教材上传、AI 分析、State–Evidence 教学内核、Canonical Presentation Blueprint、兼容适配器、课件审校,到 HTML/PPTX 双端导出和质量门禁。生产 `LessonBlueprint` 只作为 Renderer 兼容产物生成,不能绕过教学内核。 **下一步优先事项**:进行三节真实中文微课的教师主导 Pilot。浏览器自动化是并行支撑项,不阻塞真实审课。欢迎关注和贡献。 @@ -157,7 +158,7 @@ npm run test:api # 仅后端测试 npm run build:web # 前端构建校验 ``` -当前 Phase 2C 门禁:**647 passed,3 skipped**(后端)+ 前端构建/状态契约 + **14 passed** Playwright;大型真实生图生命周期为显式 opt-in。 +当前生产 cutover 门禁:**652 passed,3 skipped**(后端)+ 前端构建/状态契约;大型真实生图生命周期为显式 opt-in。 --- diff --git a/apps/api/src/hcs_api/activity_planner.py b/apps/api/src/hcs_api/activity_planner.py index 93ce826..f4d6545 100644 --- a/apps/api/src/hcs_api/activity_planner.py +++ b/apps/api/src/hcs_api/activity_planner.py @@ -25,6 +25,9 @@ def _activity_for_evidence(evidence: EvidenceSpec, learner_level: str) -> Learni "matching": "match_pairs", "role_play": "role_play", }.get(evidence.evidence_type, "guided_response") + if "character_formation" in evidence.evidence_id: + activity_type = "character_formation" + teacher_only = False return LearningActivity( id=evidence.collector_refs[0] if evidence.collector_refs else f"act_{evidence.evidence_id}", evidence_ids=[evidence.evidence_id], 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/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/analysis.py b/apps/api/src/hcs_api/analysis.py index 741184b..d99b9da 100644 --- a/apps/api/src/hcs_api/analysis.py +++ b/apps/api/src/hcs_api/analysis.py @@ -15,7 +15,10 @@ # High-priority greeting words — these are core for greeting_lesson route -GREETING_WORDS = {"你好", "您好", "你", "您", "你们", "好", "再见", "谢谢", "不客气", "对不起", "没关系"} +GREETING_WORDS = { + "你好", "您好", "你", "您", "你们", "好", "老师好", "早上好", + "再见", "谢谢", "不客气", "对不起", "没关系", +} # Stroke/numeral noise — not real vocabulary in teaching context STROKE_NOISE = {"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "横", "竖", "撇", "捺", "点", "提", "折", "钩"} diff --git a/apps/api/src/hcs_api/blueprint_compatibility.py b/apps/api/src/hcs_api/blueprint_compatibility.py index 681f9f7..c8a5328 100644 --- a/apps/api/src/hcs_api/blueprint_compatibility.py +++ b/apps/api/src/hcs_api/blueprint_compatibility.py @@ -1,88 +1,363 @@ -"""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" + + +class PresentationAdapterError(ValueError): + """Raised when a canonical mode has no explicit legacy projection.""" + + +# This is the production adapter contract. Every learner mode has an explicit +# projection; ``None`` means a deterministic static page, never an implicit +# fallback for an unknown mode. +MODE_ADAPTER_MATRIX = { + "choice_response": {"slide_type": "PracticeSlide", "component_type": "ChoiceQuestion", "title": "选择练习"}, + "listening_choice": {"slide_type": "PracticeSlide", "component_type": "ListenAndChoose", "title": "听音选择"}, + "matching_response": {"slide_type": "PracticeSlide", "component_type": "MatchGame", "title": "匹配练习"}, + "guided_response": {"slide_type": "PracticeSlide", "component_type": None, "title": "引导回应"}, + "role_play_response": {"slide_type": "DialogueSlide", "component_type": None, "title": "角色练习"}, + "character_formation": {"slide_type": "ReadingSlide", "component_type": "CharacterFormation", "title": "汉字练习"}, + "teacher_observation": {"slide_type": "PracticeSlide", "component_type": None, "title": "教师支持"}, +} +STRUCTURAL_SLIDE_TYPES = { + "lesson_opening": "CoverSlide", + "route_preview": "ObjectiveSlide", + "input_modeling": "ReadingSlide", + "consolidation_summary": "SummarySlide", +} + + 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] + teacher_units = [unit for unit in blueprint.presentation_units if 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])) + teacher_start = len(slides) + 1 + slides.extend( + _teacher_support_slide(index, unit) + for index, unit in enumerate(teacher_units, start=teacher_start) + ) return LessonBlueprint( lesson_title=blueprint.lesson_title, - key_vocabulary=[{"word": item} for item in vocabulary], + # Vocabulary authority remains in the kernel/content artifacts. Flat + # slide text is not a safe substitute for a word + pronunciation + + # scaffold-meaning contract. + key_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.""" + content_by_id = {item.id: item for item in (content_plan.content_items if content_plan else [])} + mappings: list[LegacyComponentMapping] = [] + blocking: list[str] = [] + unit_to_slide: dict[str, LessonSlide] = {} + learner_units = [unit for unit in canonical.presentation_units if not unit.teacher_channel_reference] + teacher_units = [unit for unit in canonical.presentation_units if unit.teacher_channel_reference] + learner_slides = [slide for slide in legacy.slides if not slide.teacher_only] + teacher_slides = [slide for slide in legacy.slides if slide.teacher_only] + unit_to_slide.update({unit.presentation_unit_id: slide for unit, slide in zip(learner_units, learner_slides)}) + unit_to_slide.update({unit.presentation_unit_id: slide for unit, slide in zip(teacher_units, teacher_slides)}) + for unit in canonical.presentation_units: + content_item = content_by_id.get(unit.content_item_id or "") + slide = unit_to_slide.get(unit.presentation_unit_id) + 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=not unit.teacher_channel_reference)) + continue + component = slide.components[0] if slide.components else None + mappings.append(_mapping_for_unit(unit, content_item, slide, component, learner_visible=not unit.teacher_channel_reference)) + 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, + reconciled_content_fingerprint: str = "", +) -> 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), + reconciled_content_fingerprint=reconciled_content_fingerprint, + 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: + mode = unit.presentation_mode + if mode not in MODE_ADAPTER_MATRIX: + raise PresentationAdapterError( + f"Unsupported canonical presentation mode '{mode}' for unit '{unit.presentation_unit_id}'." + ) + if content_item is not None and content_item.presentation_mode != mode: + raise PresentationAdapterError( + f"Canonical/content presentation mode mismatch for unit '{unit.presentation_unit_id}': " + f"canonical={mode}, content={content_item.presentation_mode}." + ) + 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=STRUCTURAL_SLIDE_TYPES.get(unit.structural_role, MODE_ADAPTER_MATRIX.get(mode, {}).get("slide_type", "PracticeSlide")), + layout_variant="canonical_compatibility", + title=unit.title or MODE_ADAPTER_MATRIX.get(mode, {}).get("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 _teacher_support_slide(index: int, unit) -> LessonSlide: + return LessonSlide( + id=index, + slide_type="PracticeSlide", + layout_variant="teacher_support", + title="教师支持", + content_blocks=[ + ContentBlock(id=f"teacher_{index}_instruction", text="教师观察并记录学习证据。"), + ContentBlock(id=f"teacher_{index}_remediation", text="根据学生表现进行示范、支架或再次练习。"), + ], + components=[], + teacher_only=True, ) 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, + *(option.text for option in content_item.options), + *(f"{pair.left} ↔ {pair.right}" for pair in content_item.matching_pairs), + 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)): +def _components_for_unit( + index: int, + unit, + content_item, + media_request, + *, + allow_planned_media: bool, + include_diagnostic_trace: bool, +) -> list[SlideComponent]: + mode = content_item.presentation_mode if content_item else unit.presentation_mode + if mode not in MODE_ADAPTER_MATRIX: + raise PresentationAdapterError( + f"Unsupported canonical presentation mode '{mode}' for unit '{unit.presentation_unit_id}'." + ) + if unit.unit_role == "structural": 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 ""} - 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) + 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 mode == "listening_choice": + 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": + if content_item and mode == "matching_response": if content_item.matching_pairs: 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", - component_type="VocabularyFlipCard", - data={ - "items": [{"word": content} for content in display_items], - "_shadow_trace": trace, - }, - )] + if content_item and mode == "choice_response": + if content_item.options and content_item.accepted_responses: + return [SlideComponent( + id=f"unit_{index}_choice", + component_type="ChoiceQuestion", + data=diagnostic_data({ + "choices": [option.text for option in content_item.options], + "answer": content_item.accepted_responses[0].normalized_value, + "hint": content_item.learner_safe_hint, + }), + )] + return [] + if mode == "character_formation": + character = display_items[0] if display_items else "" + if not character: + return [] + return [SlideComponent( + id=f"unit_{index}_character", + component_type="CharacterFormation", + title="汉字结构", + data=diagnostic_data({ + "character": character, + "parts": list(character), + "explanation": content_item.prompt if content_item else "观察目标汉字的结构。", + }), + )] + if mode == "guided_response" and include_diagnostic_trace and display_items: + # Diagnostic parity retains a registered trace carrier for historical + # fixtures. Production guided units remain explicit static pages. + return [SlideComponent( + id=f"unit_{index}_diagnostic_guided", + component_type="VocabularyFlipCard", + data=diagnostic_data({ + "items": [{"word": content} for content in display_items], + }), + )] + # guided_response and role_play_response are explicit static pages. Their + # approved prompt/options remain content blocks; no other mode is guessed. + return [] + + +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.structural_role or unit.unit_role, + learner_visible=learner_visible, + trace=unit.trace, + ) diff --git a/apps/api/src/hcs_api/content_contract.py b/apps/api/src/hcs_api/content_contract.py index bf713d3..2f228d4 100644 --- a/apps/api/src/hcs_api/content_contract.py +++ b/apps/api/src/hcs_api/content_contract.py @@ -14,7 +14,7 @@ "SentenceDragBuilder", "GrammarPatternSlide", "VocabularyFlipCard", "VocabularySlide", "ObjectiveSlide", "WarmUpSlide", "PracticeSlide", "SummarySlide", "CoverSlide", "DialogueSlide", - "MatchGame", "ListenAndChoose", "AudioButton", + "MatchGame", "ListenAndChoose", "ChoiceQuestion", "AudioButton", "component", "fallback", "debug", "provider_required", "Image placeholder", "Clean educational illustration", } diff --git a/apps/api/src/hcs_api/evidence.py b/apps/api/src/hcs_api/evidence.py index 4899503..be745d1 100644 --- a/apps/api/src/hcs_api/evidence.py +++ b/apps/api/src/hcs_api/evidence.py @@ -17,7 +17,16 @@ def build_evidence_plan( def _evidence_for_goal(goal: LearningGoal, state_plan: LearningStatePlan, learner_level: str) -> EvidenceSpec: evidence_id = f"ev_{goal.goal_id.removeprefix('goal_')}" is_beginner = _is_beginner(learner_level) - if goal.goal_type == "recognition": + route = state_plan.route_hint + # Route-specific contracts preserve the learner task selected by the kernel; + # they must be decided before the generic skill mapping below. + if "role_play" in goal.goal_id: + evidence_type, method, mode = "role_play", "learner_response", "teacher" + elif "character_formation" in goal.goal_id: + evidence_type, method, mode = "deterministic_choice", "learner_response", "deterministic" + elif "grammar" in goal.goal_id and route in {"grammar_pattern_lesson", "mixed_lesson"}: + evidence_type, method, mode = "constrained_production", "spoken_or_written_response", "teacher" + elif goal.goal_type == "recognition": evidence_type, method, mode = "deterministic_choice", "learner_response", "deterministic" elif goal.goal_type == "understanding": evidence_type, method, mode = "deterministic_choice", "scenario_choice", "deterministic" @@ -29,10 +38,16 @@ def _evidence_for_goal(goal: LearningGoal, state_plan: LearningStatePlan, learne else: evidence_type, method, mode = "teacher_observation", "teacher_observation", "teacher" - state_from = next( - (transition.from_state for transition in state_plan.transitions if transition.to_state == goal.required_state_to_reach), - "", + transition = next( + ( + candidate + for candidate in state_plan.transitions + if evidence_id in candidate.required_evidence_ids or evidence_id in candidate.optional_evidence_ids + ), + None, ) + state_from = transition.from_state if transition else "" + state_to = transition.to_state if transition else goal.required_state_to_reach return EvidenceSpec( id=evidence_id, goal_id=goal.goal_id, @@ -44,7 +59,7 @@ def _evidence_for_goal(goal: LearningGoal, state_plan: LearningStatePlan, learne confidence_level="high" if mode == "deterministic" else "teacher_review", limitations=["Preparatory recognition evidence only; confirm production later."] if goal.goal_type == "production" and is_beginner else [], state_from=state_from, - state_to=goal.required_state_to_reach, + state_to=state_to, target_items=goal.target_items, assessment_mode=mode, collector_refs=[f"act_{evidence_id.removeprefix('ev_')}"] , diff --git a/apps/api/src/hcs_api/evidence_alignment.py b/apps/api/src/hcs_api/evidence_alignment.py index 905c823..2d4b3b1 100644 --- a/apps/api/src/hcs_api/evidence_alignment.py +++ b/apps/api/src/hcs_api/evidence_alignment.py @@ -50,6 +50,27 @@ def check_evidence_alignment( _block(report, f"Evidence '{spec.evidence_id}' is missing goal_id.") elif spec.goal_id not in goals: _block(report, f"Evidence '{spec.evidence_id}' references invalid goal '{spec.goal_id}'.") + elif state_plan.transitions: + goal = goals[spec.goal_id] + transitions = [ + transition + for transition in state_plan.transitions + if spec.evidence_id in transition.required_evidence_ids + or spec.evidence_id in transition.optional_evidence_ids + ] + if not transitions: + _block(report, f"Evidence '{spec.evidence_id}' is not attached to a learning transition.") + elif any( + transition.from_state != spec.state_from + or transition.to_state != spec.state_to + or transition.to_state != goal.required_state_to_reach + for transition in transitions + ): + _block( + report, + f"Evidence '{spec.evidence_id}' state contract does not match goal " + f"'{goal.goal_id}' and its learning transition.", + ) if _contains_forbidden_reference(spec.model_dump(mode="json"), FORBIDDEN_EVIDENCE_TERMS): message = f"Evidence '{spec.evidence_id}' contains presentation or layout details." report.presentation_independence.append(message) diff --git a/apps/api/src/hcs_api/learning_kernel.py b/apps/api/src/hcs_api/learning_kernel.py index 5c655e9..ed86dcc 100644 --- a/apps/api/src/hcs_api/learning_kernel.py +++ b/apps/api/src/hcs_api/learning_kernel.py @@ -23,10 +23,18 @@ def build_learning_state_plan( constraints=[f"Use {profile.scaffolding_language} only as support; Chinese remains the target language."], risks=["Do not require open production before recognition is established."], ) - if route == "greeting_lesson" or not vocabulary: + if route == "greeting_lesson": _build_greeting_plan(plan, vocabulary or ["你好", "您好", "你", "您"]) + elif route == "vocabulary_lesson": + _build_vocabulary_plan(plan, vocabulary[:6] or ["你好", "谢谢"]) + elif route == "dialogue_lesson": + _build_dialogue_plan(plan, candidates, vocabulary[:6] or ["你好", "谢谢"]) + elif route == "character_lesson": + _build_character_plan(plan, candidates, vocabulary[:6] or ["你", "好"]) + elif route == "grammar_pattern_lesson": + _build_grammar_plan(plan, candidates, vocabulary[:6] or ["你好", "谢谢"]) else: - _build_recognition_plan(plan, vocabulary[:4]) + _build_mixed_plan(plan, candidates, vocabulary[:6] or ["你好", "谢谢"]) return plan @@ -84,3 +92,186 @@ def _build_recognition_plan(plan: LearningStatePlan, vocabulary: list[str]) -> N LearningTransition(from_state="unseen", to_state="noticed", transition_intent="first_exposure", transition_policy="exposure_only"), LearningTransition(from_state="noticed", to_state="recognized", transition_intent="recognition", required_evidence_ids=["ev_recognize"]), ] + + +def _set_route_states(plan: LearningStatePlan, target_items: list[str], goals: list[LearningGoal]) -> None: + """Keep the kernel compact while giving every route more than one observable step.""" + plan.states = [ + LearningState(state_id="unseen", state_type="unseen", target_items=target_items), + LearningState(state_id="noticed", state_type="noticed", target_items=target_items, prerequisites=["unseen"]), + LearningState(state_id="understood", state_type="understood", target_items=target_items, prerequisites=["noticed"]), + LearningState(state_id="controlled", state_type="controlled_production", target_items=target_items, prerequisites=["understood"]), + ] + plan.learning_goals = goals + understood_evidence = [ + f"ev_{goal.goal_id.removeprefix('goal_')}" + for goal in goals + if goal.required_state_to_reach == "understood" + ] + controlled_evidence = [ + f"ev_{goal.goal_id.removeprefix('goal_')}" + for goal in goals + if goal.required_state_to_reach == "controlled" + ] + plan.transitions = [ + LearningTransition( + from_state="unseen", + to_state="noticed", + transition_intent="first_exposure", + transition_policy="exposure_only", + ), + ] + if understood_evidence: + plan.transitions.append(LearningTransition( + from_state="noticed", + to_state="understood", + transition_intent="understanding", + required_evidence_ids=understood_evidence, + )) + if controlled_evidence: + plan.transitions.append(LearningTransition( + from_state="understood", + to_state="controlled", + transition_intent="controlled_production", + required_evidence_ids=controlled_evidence, + )) + + +def _build_vocabulary_plan(plan: LearningStatePlan, vocabulary: list[str]) -> None: + goals = [ + _goal( + "goal_vocabulary_recognition", + "Recognize the core vocabulary for this lesson.", + "recognition", + vocabulary, + "understood", + ), + _goal( + "goal_vocabulary_use", + "Use the core vocabulary in a guided response.", + "production", + vocabulary[:3], + "controlled", + ), + ] + _set_route_states(plan, vocabulary, goals) + + +def _build_dialogue_plan(plan: LearningStatePlan, candidates, vocabulary: list[str]) -> None: + dialogue = [ + f"{item.get('speaker', '')}:{item.get('text', '')}".strip(":") + for item in candidates.dialogue_candidates + if item.get("text") + ] + dialogue = dialogue[:4] or ["A:你好!", "B:你好!"] + goals = [ + _goal( + "goal_dialogue_input", + "Follow the approved dialogue input and identify its meaning.", + "understanding", + dialogue, + "understood", + ), + _goal( + "goal_dialogue_response", + "Choose an appropriate response from the approved dialogue.", + "recognition", + vocabulary[:3], + "understood", + ), + _goal( + "goal_role_play_dialogue", + "Role-play the approved dialogue with a partner.", + "communicative", + dialogue, + "controlled", + ), + ] + _set_route_states(plan, list(dict.fromkeys([*dialogue, *vocabulary])), goals) + + +def _build_character_plan(plan: LearningStatePlan, candidates, vocabulary: list[str]) -> None: + characters = candidates.character_candidates or list(dict.fromkeys("".join(vocabulary)))[:6] or ["你", "好"] + goals = [ + _goal( + "goal_character_recognition", + "Recognize the target Chinese characters and their forms.", + "recognition", + characters, + "understood", + ), + _goal( + "goal_character_formation", + "Trace the approved character strokes and formation.", + "understanding", + characters, + "controlled", + ), + ] + _set_route_states(plan, characters, goals) + + +def _build_grammar_plan(plan: LearningStatePlan, candidates, vocabulary: list[str]) -> None: + patterns = [item.get("pattern", "") for item in candidates.grammar_candidates if item.get("pattern")] + patterns = patterns[:4] or ["approved sentence pattern"] + goals = [ + _goal( + "goal_grammar_pattern", + f"Identify the approved grammar pattern: {patterns[0]}.", + "understanding", + patterns, + "understood", + ), + _goal( + "goal_apply_grammar_pattern", + f"Apply the approved grammar pattern {patterns[0]} in a guided response.", + "production", + patterns + vocabulary[:2], + "controlled", + ), + ] + _set_route_states(plan, list(dict.fromkeys([*patterns, *vocabulary])), goals) + + +def _build_mixed_plan(plan: LearningStatePlan, candidates, vocabulary: list[str]) -> None: + goals = [ + _goal( + "goal_mixed_vocabulary", + "Recognize the core vocabulary in this mixed lesson.", + "recognition", + vocabulary, + "understood", + ), + ] + if candidates.dialogue_candidates: + goals.append( + _goal( + "goal_role_play_mixed_dialogue", + "Use the approved dialogue input in a short role-play.", + "communicative", + [item.get("text", "") for item in candidates.dialogue_candidates[:4] if item.get("text")] or vocabulary, + "controlled", + ) + ) + elif candidates.grammar_candidates: + pattern = candidates.grammar_candidates[0].get("pattern", "approved sentence pattern") + goals.append( + _goal( + "goal_mixed_grammar_pattern", + f"Apply the approved grammar pattern {pattern}.", + "production", + [pattern, *vocabulary[:2]], + "controlled", + ) + ) + else: + goals.append( + _goal( + "goal_mixed_guided_response", + "Use the approved vocabulary in a guided response.", + "production", + vocabulary[:3], + "controlled", + ) + ) + _set_route_states(plan, vocabulary, goals) diff --git a/apps/api/src/hcs_api/main.py b/apps/api/src/hcs_api/main.py index 719e1b5..229a3ed 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,19 @@ 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 ( + finalize_production_media_contract, + 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 +1746,47 @@ 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 {} + eligibility_report = read_json(project_id, "quality/production_presentation_eligibility_report.json") or {} + if not state.lesson_blueprint or report.get("state") == "blocked": + eligibility_blocked = eligibility_report.get("state") == "blocked" if isinstance(eligibility_report, dict) else False + raise HTTPException( + status_code=409, + detail={ + "code": "production_presentation_eligibility_blocked" if eligibility_blocked else "presentation_stage_blocked", + "message": report.get("message", "Canonical presentation production is blocked."), + "route": eligibility_report.get("route") if eligibility_blocked else None, + "error_codes": eligibility_report.get("error_codes", []) if eligibility_blocked else [], + "blocking_reasons": ( + eligibility_report.get("blocking") + if eligibility_blocked and eligibility_report.get("blocking") + else 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 +1795,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 +1810,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", require_reconciled=False) blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) if not blueprint: raise HTTPException(status_code=400, detail="Generate a lesson blueprint first") @@ -1800,6 +1835,7 @@ def generate_media(project_id: str, force_regenerate: bool = Query(False), expec ) from exc write_model(project_id, "asset_manifest.json", manifest) write_json(project_id, "assets/data/attribution.json", {"schema": "hanclassstudio.attribution.v1", "items": []}) + _raise_if_media_contract_blocked(project_id, finalize_production_media_contract(project_id, manifest)) clear_stale_state(project_id, stages={"profile", "design", "presentation", "media"}) bump_project_revision(project_id) return get_project_state(project_id) @@ -1875,6 +1911,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", require_reconciled=False) manifest = read_model(project_id, "asset_manifest.json", AssetManifest) if not manifest: raise HTTPException(status_code=404, detail="Asset manifest not found") @@ -1884,6 +1921,7 @@ def review_media(project_id: str, asset_id: str, action: MediaReviewAction, expe raise HTTPException(status_code=400, detail=str(exc)) from exc write_model(project_id, "asset_manifest.json", manifest) invalidate_downstream(project_id, "media", "A teacher-reviewed media asset changed; render, quality, and export are stale.") + _raise_if_media_contract_blocked(project_id, finalize_production_media_contract(project_id, manifest)) clear_stale_state(project_id, stages={"presentation", "media"}) bump_project_revision(project_id) return asset @@ -1896,6 +1934,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", require_reconciled=False) manifest = read_model(project_id, "asset_manifest.json", AssetManifest) if not manifest: raise HTTPException(status_code=404, detail="Asset manifest not found") @@ -1907,6 +1946,7 @@ async def replace_media( raise HTTPException(status_code=400, detail=str(exc)) from exc write_model(project_id, "asset_manifest.json", manifest) invalidate_downstream(project_id, "media", "A teacher replacement changed media; render, quality, and export are stale.") + _raise_if_media_contract_blocked(project_id, finalize_production_media_contract(project_id, manifest)) clear_stale_state(project_id, stages={"presentation", "media"}) bump_project_revision(project_id) return asset @@ -1917,6 +1957,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", require_reconciled=False) 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) @@ -1955,6 +1996,15 @@ def render_project(project_id: str, expected_revision: int | None = Query(defaul }, ) from exc write_model(project_id, "asset_manifest.json", manifest) + finalization = finalize_production_media_contract(project_id, manifest) + _raise_if_media_contract_blocked(project_id, finalization) + blueprint = finalization.legacy or read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + if not blueprint: + raise HTTPException(status_code=409, detail={ + "code": "presentation_media_contract_blocked", + "message": "Media finalization did not produce a compatibility Blueprint.", + }) + _assert_canonical_presentation_current(project_id, action="render", require_reconciled=True) clear_stale_state(project_id, stages={"profile", "design", "presentation", "media"}) render_and_check(project_id, root, profile, blueprint, manifest) export_created = False @@ -1987,14 +2037,19 @@ 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", "presentation/binding_quality_report.json", "quality/quality_report.json", + "quality/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_report.json", + "quality/presentation_revision_plan.json", ) has_blocked_gate = any( isinstance(report, dict) and report.get("state") in {"blocked", "failed"} @@ -2056,7 +2111,7 @@ def force_export_project(project_id: str, force: bool = Query(default=False)) -> raise HTTPException(status_code=409, detail=_export_technical_detail(state, technical_reason)) if not force and not state.gate_summary.export_allowed: raise HTTPException(status_code=409, detail=_export_gate_detail(state, forced=False)) - if force and not state.gate_summary.force_export_allowed: + if force and not state.gate_summary.export_allowed: raise HTTPException(status_code=409, detail=_export_gate_detail(state, forced=True)) try: export_path = zip_output(project_id, force=force) @@ -2078,7 +2133,7 @@ def export_project_editable_pptx(project_id: str, force: bool = Query(default=Fa raise HTTPException(status_code=409, detail=_export_technical_detail(current_state, technical_reason)) if not force and not current_state.gate_summary.export_allowed: raise HTTPException(status_code=409, detail=_export_gate_detail(current_state, forced=False)) - if force and not current_state.gate_summary.force_export_allowed: + if force and not current_state.gate_summary.export_allowed: raise HTTPException(status_code=409, detail=_export_gate_detail(current_state, forced=True)) try: export_path = export_editable_pptx(project_id, force=force, export_mode=export_mode) @@ -2117,7 +2172,10 @@ def _export_gate_detail(state: ProjectState, *, forced: bool, message: str | Non reasons.append("Export prerequisites are not satisfied") return { "code": "export_gate_blocked", - "message": message or ("Forced export is unavailable" if forced else "Export is blocked by the project gates"), + "message": message or ( + "The force parameter cannot bypass the release quality gate" + if forced else "Export is blocked by the project gates" + ), "blocking_reasons": reasons, "warnings": summary.warnings, "gate_summary": summary.model_dump(mode="json"), @@ -2143,8 +2201,31 @@ def _export_technical_detail(state: ProjectState, message: str) -> dict: 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"): + blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + if not state.artifacts.get("lesson_blueprint") or blueprint is None: 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" + if _is_production_compatibility_blueprint(blueprint): + for path, label in ( + ("presentation/presentation_content_plan.reconciled.json", "Reconciled presentation content plan"), + ("quality/presentation_asset_reconciliation_report.json", "Presentation asset reconciliation report"), + ): + if not (root / path).is_file(): + return f"{label} artifact is missing; export cannot proceed" + reconciliation = read_json(project_id, "quality/presentation_asset_reconciliation_report.json") + if not isinstance(reconciliation, dict) or reconciliation.get("state") == "blocked": + return "Presentation asset reconciliation is blocked or missing; export cannot proceed" + provenance = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + expected = provenance.get("reconciled_content_fingerprint") if isinstance(provenance, dict) else "" + if not expected or artifact_fingerprint(project_id, "presentation/presentation_content_plan.reconciled.json") != expected: + return "Reconciled presentation provenance is stale; 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" @@ -2158,6 +2239,28 @@ def _assert_project(project_id: str) -> Path: return root +def _is_production_compatibility_blueprint(blueprint: LessonBlueprint) -> bool: + return bool( + blueprint.artifact_role == "legacy_compatibility" + and blueprint.canonical_source_artifact == "presentation/presentation_blueprint.json" + and blueprint.provenance_artifact == "presentation/legacy_blueprint_provenance.json" + ) + + +def _raise_if_media_contract_blocked(project_id: str, finalization) -> None: + if finalization.state != "blocked": + return + raise HTTPException( + status_code=409, + detail={ + "code": "presentation_media_contract_blocked", + "project_id": project_id, + "blocking_reasons": list(finalization.blocking), + "message": "Post-media reconciliation or canonical compatibility finalization is blocked; render/export stopped.", + }, + ) + + def _assert_expected_revision(project_id: str, expected_revision: int | None) -> None: """Reject stale client writes while keeping legacy callers compatible.""" if expected_revision is None: @@ -2196,6 +2299,112 @@ def _assert_upstream_current(project_id: str, *, blocked_stages: set[str], actio ) +def _assert_canonical_presentation_current( + project_id: str, + *, + action: str, + require_reconciled: bool = True, +) -> 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/presentation_teacher_plan.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", + "quality/production_presentation_eligibility_report.json", + ) + if require_reconciled: + required += ( + "presentation/presentation_content_plan.reconciled.json", + "quality/presentation_asset_reconciliation_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/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_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, require_reconciled=require_reconciled) + if not require_reconciled: + blocked_reports = [path for path in blocked_reports if path != "quality/presentation_asset_reconciliation_report.json"] + 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, *, require_reconciled: bool = True) -> 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}.") + if require_reconciled: + reconciled_path = "presentation/presentation_content_plan.reconciled.json" + reconciled_report = read_json(project_id, "quality/presentation_asset_reconciliation_report.json") + if not isinstance(reconciled_report, dict) or reconciled_report.get("state") == "blocked": + findings.append("Post-media presentation reconciliation is missing or blocked.") + expected_reconciled = provenance.reconciled_content_fingerprint + actual_reconciled = artifact_fingerprint(project_id, reconciled_path) + if not expected_reconciled or actual_reconciled != expected_reconciled: + findings.append("Reconciled presentation content provenance is missing or stale.") + 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 +2451,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" diff --git a/apps/api/src/hcs_api/models.py b/apps/api/src/hcs_api/models.py index 018f6ff..cbcdf5c 100644 --- a/apps/api/src/hcs_api/models.py +++ b/apps/api/src/hcs_api/models.py @@ -285,15 +285,21 @@ class LessonSlide(BaseModel): content_blocks: list[ContentBlock] = Field(default_factory=list) components: list[SlideComponent] = Field(default_factory=list) media_requirements: MediaRequirements = Field(default_factory=MediaRequirements) + teacher_only: bool = False 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 +878,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 @@ -1365,7 +1373,7 @@ class RealizationReport(BaseModel): TraditionalLayout = Literal[ "cover_title", "objectives_cards", "single_item_focus", - "two_card_contrast", "listen_choose", "dialogue_bubbles", + "two_card_contrast", "choice_question", "listen_choose", "dialogue_bubbles", "match_pairs", "summary_cards", "generic_content", ] @@ -1648,6 +1656,7 @@ class V2RenderedOutputReviewReport(BaseModel): "matching_response", "guided_response", "role_play_response", + "character_formation", "teacher_observation", ] @@ -1663,6 +1672,34 @@ class PresentationTrace(BaseModel): evidence_ids: list[str] +class ProductionPresentationEligibilityReport(BaseModel): + """Mandatory route/semantic gate before production presentation output.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.production_presentation_eligibility.v1", alias="schema") + state: QualityState = "pass" + route: str = "" + route_supported: bool = False + goals_covered: bool = False + presentation_modes_supported: bool = False + structural_roles_complete: bool = False + teacher_channel_resolved: bool = False + supported_presentation_modes: list[str] = Field(default_factory=list) + unsupported_presentation_modes: list[str] = Field(default_factory=list) + required_presentation_modes: list[str] = Field(default_factory=list) + missing_required_presentation_modes: list[str] = Field(default_factory=list) + required_structural_roles: list[str] = Field(default_factory=list) + present_structural_roles: list[str] = Field(default_factory=list) + missing_structural_roles: list[str] = Field(default_factory=list) + goal_findings: list[str] = Field(default_factory=list) + teacher_channel_findings: list[str] = Field(default_factory=list) + error_codes: list[str] = Field(default_factory=list) + blocking: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + source_artifacts: list[str] = Field(default_factory=list) + + class AbstractPresentationBinding(BaseModel): """Binding-first, renderer-independent projection of one planned activity.""" @@ -1706,6 +1743,8 @@ class PresentationUnit(BaseModel): evidence_ids: list[str] content_item_id: str | None = None unit_role: str + title: str = "" + structural_role: str = "" learner_channel: list[AbstractLearnerChannel] teacher_channel: list[AbstractTeacherChannel] presentation_mode: AbstractPresentationMode @@ -1719,6 +1758,36 @@ class PresentationUnit(BaseModel): trace: PresentationTrace +class PresentationTeacherPlanItem(BaseModel): + """Teacher-channel delivery contract derived from an approved activity.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + id: str + activity_id: str + evidence_ids: list[str] = Field(default_factory=list) + observation_instructions: list[str] = Field(default_factory=list) + success_criteria: list[str] = Field(default_factory=list) + fallback_remediation: list[str] = Field(default_factory=list) + target_presentation_unit_id: str + target_legacy_slide_id: int | None = None + target_legacy_component_id: str | None = None + trace: PresentationTrace + + +class PresentationTeacherPlan(BaseModel): + """Non-learner-facing teacher delivery plan for observation evidence.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.presentation_teacher_plan.v1", alias="schema") + state: QualityState = "pass" + items: list[PresentationTeacherPlanItem] = Field(default_factory=list) + source_artifacts: list[str] = Field(default_factory=list) + blocking: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + class CanonicalPresentationBlueprint(BaseModel): """Canonical v2 presentation contract; it contains no kernel objects or layout.""" @@ -1914,7 +1983,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 +2007,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 +2123,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 +2135,70 @@ 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 = "" + reconciled_content_plan_path: str = "presentation/presentation_content_plan.reconciled.json" + reconciled_content_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..47d6a32 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,42 @@ ) 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, + PresentationTeacherPlan, + ProductionPresentationEligibilityReport, + 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, + release_quality_gate_blockers, + 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 +61,26 @@ ) -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", + "presentation/presentation_teacher_plan.json", + "quality/presentation_content_report.json", + "quality/presentation_media_request_report.json", + "quality/presentation_asset_reconciliation_report.json", + "quality/presentation_shadow_report.json", + "quality/presentation_readiness_report.json", + "quality/production_presentation_eligibility_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 +93,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 +125,69 @@ "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", + "presentation/presentation_teacher_plan.json", + "quality/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_report.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 + teacher_plan: PresentationTeacherPlan | None = None + eligibility: ProductionPresentationEligibilityReport | None = None + blocked: bool = False + def _remove_project_artifacts(project_id: str, relative_paths: tuple[str, ...]) -> None: import shutil @@ -71,7 +203,80 @@ 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/presentation_teacher_plan.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", + "quality/production_presentation_eligibility_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/production_presentation_eligibility_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 +284,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 +295,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 +336,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 +359,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 +389,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 +400,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 +413,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 +472,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 +502,573 @@ 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_presentation_teacher_plan( + project_id: str, + canonical_blueprint, + evidence_plan, + activity_plan, + mapping_plan=None, +): + from .presentation_teacher import build_presentation_teacher_plan + + plan = build_presentation_teacher_plan(canonical_blueprint, evidence_plan, activity_plan, mapping_plan) + write_json(project_id, "presentation/presentation_teacher_plan.json", plan.model_dump(mode="json", by_alias=True)) + return plan + + +def write_legacy_compatibility_artifacts( + project_id: str, + canonical_blueprint, + content_plan=None, + media_request_plan=None, + *, + allow_planned_media: bool = False, + evidence_plan=None, + activity_plan=None, +): + """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) + teacher_plan = None + if evidence_plan is not None and activity_plan is not None: + teacher_plan = write_presentation_teacher_plan( + project_id, canonical_blueprint, evidence_plan, activity_plan, mapping, + ) + 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, + reconciled_content_fingerprint=artifact_fingerprint( + project_id, "presentation/presentation_content_plan.reconciled.json" + ) or "", + ) + 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 + + +@dataclass(frozen=True) +class _ProductionMediaFinalization: + state: str + reconciliation: Any | None = None + legacy: LessonBlueprint | None = None + mapping: Any | None = None + provenance: Any | None = None + teacher_plan: PresentationTeacherPlan | None = None + binding_plan: PresentationBindingPlan | None = None + readiness: Any | None = None + blocking: tuple[str, ...] = () + + +def finalize_production_media_contract( + project_id: str, + manifest: AssetManifest, + *, + stage: _BlueprintStage | None = None, +) -> _ProductionMediaFinalization: + """Close the same reconciled production contract for every media entrypoint.""" + from .models import CanonicalPresentationBlueprint + from .presentation_asset_reconciliation import run_post_media_presentation_reconciliation + from .presentation_media_requests import run_presentation_media_asset_linkage + + write_model(project_id, "asset_manifest.json", manifest) + run_presentation_media_asset_linkage(project_id, manifest) + reconciliation = run_post_media_presentation_reconciliation(project_id, manifest) + if reconciliation.state == "blocked": + blocking = tuple(reconciliation.blocking or ("Post-media presentation reconciliation is blocked.",)) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact( + project_id, blocking=blocking, stage="presentation_asset_reconciliation", + ) + return _ProductionMediaFinalization(state="blocked", reconciliation=reconciliation, blocking=blocking) + + 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: + blocking = ("Reconciled canonical presentation inputs are missing.",) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="presentation_asset_reconciliation") + return _ProductionMediaFinalization(state="blocked", reconciliation=reconciliation, blocking=blocking) + + state_plan = stage.state_plan if stage else _read_artifact_model(project_id, "learning/learning_state_plan.json", LearningStatePlan) + evidence_plan = stage.evidence_plan if stage else _read_artifact_model(project_id, "learning/evidence_plan.json", EvidencePlan) + activity_plan = stage.activity_plan if stage else _read_artifact_model(project_id, "learning/activity_plan.json", ActivityPlan) + profile = stage.profile if stage else read_model(project_id, "lesson_profile.json", LessonProfile) + if not state_plan or not evidence_plan or not activity_plan: + blocking = ("Kernel artifacts are missing; media finalization cannot rerun the compatibility contract.",) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="presentation_asset_reconciliation") + return _ProductionMediaFinalization(state="blocked", reconciliation=reconciliation, blocking=blocking) + + 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 + try: + legacy, mapping, provenance = write_legacy_compatibility_artifacts( + project_id, + canonical, + content_plan, + media_request_plan, + allow_planned_media=False, + evidence_plan=evidence_plan, + activity_plan=activity_plan, + ) + except Exception as exc: + from .blueprint_compatibility import PresentationAdapterError + + if not isinstance(exc, PresentationAdapterError): + raise + blocking = (f"Canonical presentation adapter rejected production mode: {exc}",) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact(project_id, blocking=list(blocking), stage="legacy_adapter") + return _ProductionMediaFinalization(state="blocked", reconciliation=reconciliation, blocking=blocking) + teacher_payload = read_json(project_id, "presentation/presentation_teacher_plan.json") + teacher_plan = PresentationTeacherPlan.model_validate(teacher_payload) if teacher_payload else None + if legacy is None or mapping.state == "blocked" or not teacher_plan or teacher_plan.state == "blocked": + blocking = tuple( + [*mapping.blocking, *(teacher_plan.blocking if teacher_plan else ["Teacher channel plan is missing."])] + or ["Canonical presentation could not be finalized through the legacy adapter."] + ) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="legacy_adapter") + return _ProductionMediaFinalization( + state="blocked", reconciliation=reconciliation, mapping=mapping, provenance=provenance, + teacher_plan=teacher_plan, blocking=blocking, + ) + + learner_level = ( + str(stage.difficulty.estimated_level) + if stage and hasattr(stage.difficulty, "estimated_level") + else str(getattr(profile, "learner_level", "zero_beginner") if profile else state_plan.learner_level) + ) + binding_plan = write_presentation_bindings( + project_id, legacy, evidence_plan, activity_plan, state_plan, learner_level, mapping, + ) + alignment = stage.alignment if stage else EvidenceAlignmentReport.model_validate( + read_json(project_id, "quality/evidence_alignment_report.json") or {} + ) + 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 = tuple([*binding_plan.blocking, *readiness.blocking]) + _remove_media_blocked_artifacts(project_id) + _write_presentation_revision_artifact(project_id, blocking=blocking, stage="presentation_readiness") + return _ProductionMediaFinalization( + state="blocked", reconciliation=reconciliation, mapping=mapping, provenance=provenance, + teacher_plan=teacher_plan, binding_plan=binding_plan, readiness=readiness, blocking=blocking, + ) + return _ProductionMediaFinalization( + state="pass", reconciliation=reconciliation, legacy=legacy, mapping=mapping, + provenance=provenance, teacher_plan=teacher_plan, binding_plan=binding_plan, + readiness=readiness, + ) + + +def _remove_media_blocked_artifacts(project_id: str) -> None: + _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/presentation_teacher_plan.json", + "presentation/activity_bindings.json", + "presentation/binding_quality_report.json", + "quality/presentation_readiness_report.json", + )) + + +def _read_artifact_model(project_id: str, path: str, model_type): + payload = read_json(project_id, path) + return model_type.model_validate(payload) if isinstance(payload, dict) else None + + +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] = [] + blocking: list[str] = [] + for mapping in mapping_plan.mappings: + activity = activities.get(mapping.activity_id) + if activity is None: + continue + if mapping.legacy_slide_id is None: + blocking.append( + f"Canonical mapping '{mapping.binding_id}' has no legacy slide target; no final slide_id=0 placeholder is allowed." + ) + 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, + 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, blocking=blocking), 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) + + teacher_plan = write_presentation_teacher_plan( + project_id, canonical, evidence_plan, activity_plan, + ) + from .presentation_eligibility import evaluate_production_presentation_eligibility + + eligibility = evaluate_production_presentation_eligibility( + state_plan, evidence_plan, activity_plan, canonical, teacher_plan, candidates, + ) + write_json( + project_id, + "quality/production_presentation_eligibility_report.json", + eligibility.model_dump(mode="json", by_alias=True), + ) + if eligibility.state == "blocked": + _write_presentation_revision_artifact( + project_id, blocking=eligibility.blocking, stage="production_presentation_eligibility", + ) + return _BlueprintStage( + **base, + abstract_bindings=abstract_bindings, + canonical=canonical, + teacher_plan=teacher_plan, + eligibility=eligibility, + 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), + ) + + try: + legacy, mapping, provenance = write_legacy_compatibility_artifacts( + project_id, + canonical, + content_plan, + media_request_plan, + allow_planned_media=True, + evidence_plan=evidence_plan, + activity_plan=activity_plan, + ) + except Exception as exc: + from .blueprint_compatibility import PresentationAdapterError + + if not isinstance(exc, PresentationAdapterError): + raise + blocking = (f"Canonical presentation adapter rejected production mode: {exc}",) + eligibility.state = "blocked" + if "PRODUCTION_PRESENTATION_MODE_UNSUPPORTED" not in eligibility.error_codes: + eligibility.error_codes.append("PRODUCTION_PRESENTATION_MODE_UNSUPPORTED") + eligibility.blocking.extend(item for item in blocking if item not in eligibility.blocking) + write_json( + project_id, + "quality/production_presentation_eligibility_report.json", + eligibility.model_dump(mode="json", by_alias=True), + ) + _write_presentation_revision_artifact(project_id, blocking=list(blocking), stage="legacy_adapter") + return _BlueprintStage( + **base, + abstract_bindings=abstract_bindings, + canonical=canonical, + content_plan=content_plan, + media_request_plan=media_request_plan, + teacher_plan=teacher_plan, + eligibility=eligibility, + blocked=True, + ) + teacher_payload = read_json(project_id, "presentation/presentation_teacher_plan.json") + resolved_teacher_plan = ( + PresentationTeacherPlan.model_validate(teacher_payload) + if teacher_payload else teacher_plan + ) + 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, + teacher_plan=resolved_teacher_plan, + eligibility=eligibility, + 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, + teacher_plan=resolved_teacher_plan, + eligibility=eligibility, + 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, + teacher_plan=resolved_teacher_plan, + eligibility=eligibility, + ) + + +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 +1150,29 @@ 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") - - 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 - ) - - 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]) - - # 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")) + stage = _compile_blueprint_stage(project_id, settings) + if stage.blocked or stage.legacy is None: + 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", - ) - 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, + # 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, ) - 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 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. + finalization = finalize_production_media_contract(project_id, manifest, stage=stage) + if finalization.state == "blocked" or finalization.legacy is None: return get_project_state(project_id) + legacy = finalization.legacy - # 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 +1181,34 @@ 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: - zip_output(project_id, force=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"}, + ) + if not release_quality_gate_blockers(project_id): + zip_output(project_id, force=force_export) return get_project_state(project_id) diff --git a/apps/api/src/hcs_api/pptx_deck.py b/apps/api/src/hcs_api/pptx_deck.py index e380291..806d237 100644 --- a/apps/api/src/hcs_api/pptx_deck.py +++ b/apps/api/src/hcs_api/pptx_deck.py @@ -173,17 +173,45 @@ def _map_slide_to_deck( deck.teacher_notes = ["Play audio. Students listen and repeat.", "Practice with a partner."] deck.speaker_notes = ["Listen to the dialogue, then practice with your classmate."] + elif st == "ReadingSlide": + texts = [b.text for b in slide.content_blocks[:6] if b.text] + character = next( + (str(component.data.get("character", "")) for component in slide.components if component.component_type == "CharacterFormation"), + "", + ) + deck.traditional_layout = "single_item_focus" + deck.main_focus = character or (texts[0] if texts else title or "输入与示范") + deck.target_text = "\n".join(texts) if texts else character + deck.teacher_notes = ["Model the approved input before the learner activity."] + deck.speaker_notes = ["Model the approved input, then check learner understanding."] + if character: + deck.teacher_notes.append("Demonstrate the approved character formation.") + deck.speaker_notes.append(f"Character target: {character}") + elif st == "PracticeSlide": component_types = {component.component_type for component in slide.components} - deck.traditional_layout = "listen_choose" if "ListenAndChoose" in component_types else "match_pairs" + if "ChoiceQuestion" in component_types: + deck.traditional_layout = "choice_question" + elif "ListenAndChoose" in component_types: + deck.traditional_layout = "listen_choose" + elif "MatchGame" in component_types: + deck.traditional_layout = "match_pairs" + elif "VocabularyFlipCard" in component_types: + deck.traditional_layout = "single_item_focus" + else: + deck.traditional_layout = "single_item_focus" pairs = [] for c in slide.components: for p in c.data.get("pairs", []): pairs.append(f"{p.get('left','')} ↔ {p.get('right','')}") deck.main_focus = title - deck.target_text = "\n".join(pairs[:6]) - deck.teacher_notes = ["Matching activity.", "Answer key is in speaker notes."] - deck.speaker_notes = ["Answers:"] + pairs + block_text = [b.text for b in slide.content_blocks[:6] if b.text] + deck.target_text = "\n".join((pairs[:6] or block_text)) + deck.teacher_notes = ["Present the approved learner activity."] + deck.speaker_notes = ["Facilitate the approved learner activity."] + if pairs: + deck.teacher_notes.append("Answer key is in speaker notes.") + deck.speaker_notes.extend(["Answers:", *pairs]) deck.visual_hint = "match_pairs" elif st == "SummarySlide": diff --git a/apps/api/src/hcs_api/pptx_design.py b/apps/api/src/hcs_api/pptx_design.py index a32df56..42cb01e 100644 --- a/apps/api/src/hcs_api/pptx_design.py +++ b/apps/api/src/hcs_api/pptx_design.py @@ -84,6 +84,7 @@ def profile_for_theme(theme) -> PptMasterDesignProfile: "objectives_cards": LayoutRecipe("objectives", 0.68, 0.7, 1.55, 4), "single_item_focus": LayoutRecipe("vocabulary_focus", 0.68, 0.7, 1.5, 6), "two_card_contrast": LayoutRecipe("formal_informal_contrast", 0.68, 0.7, 1.55, 2), + "choice_question": LayoutRecipe("choice_question", 0.68, 0.7, 1.55, 4), "listen_choose": LayoutRecipe("listening_choice", 0.68, 0.7, 1.55, 4), "dialogue_bubbles": LayoutRecipe("visual_scene", 0.68, 0.7, 1.45, 4), "match_pairs": LayoutRecipe("matching_activity", 0.68, 0.7, 1.55, 6), diff --git a/apps/api/src/hcs_api/pptx_exporter.py b/apps/api/src/hcs_api/pptx_exporter.py index 5e4f2a2..4461276 100644 --- a/apps/api/src/hcs_api/pptx_exporter.py +++ b/apps/api/src/hcs_api/pptx_exporter.py @@ -16,7 +16,14 @@ from .models import AssetManifest, LessonBlueprint, LessonProfile, QualityReport from .pptx_design import PROFILE, RECIPES, profile_for_theme from .presentation_theme import presentation_theme_for_project -from .storage import ensure_project, read_json, read_model, write_json +from .storage import ( + artifact_fingerprint, + assert_release_quality_gate, + ensure_project, + read_json, + read_model, + write_json, +) SUPPORTED_SLIDE_TYPES = { @@ -63,30 +70,24 @@ def export_editable_pptx(project_id: str, force: bool = False, export_mode: str blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) if not blueprint: raise ValueError("Project needs blueprints/lesson_blueprint.json before editable PPTX export") + is_production_compatibility = _is_production_compatibility_blueprint(blueprint) + if is_production_compatibility: + assert_release_quality_gate(project_id) + _assert_reconciled_production_contract(project_id) + else: + # Low-level renderer fixtures may use a hand-authored legacy blueprint, + # but any production report that exists still has release authority. + assert_release_quality_gate( + project_id, + required_reports=(("Quality", "quality/quality_report.json"),), + ) report = read_model(project_id, "quality_report.json", QualityReport) - if not report and not force: - raise PermissionError("Run quality gate before editable PPTX export") - if report and report.state == "blocked" and not force: - raise PermissionError("Quality gate is blocked; pass force=true to export editable PPTX anyway") alignment_report = read_json(project_id, "quality/evidence_alignment_report.json") or {} - if isinstance(alignment_report, dict) and alignment_report.get("state") == "blocked" and not force: - raise PermissionError("Evidence alignment gate is blocked; pass force=true to export editable PPTX anyway") readiness_report = read_json(project_id, "quality/presentation_readiness_report.json") or {} - if isinstance(readiness_report, dict) and readiness_report.get("state") == "blocked" and not force: - raise PermissionError("Presentation readiness gate is blocked; pass force=true to export editable PPTX anyway") binding_report = read_json(project_id, "presentation/binding_quality_report.json") or {} - if isinstance(binding_report, dict) and binding_report.get("state") == "blocked" and not force: - raise PermissionError("Presentation binding gate is blocked; pass force=true to export editable PPTX anyway") - # Classroom mode: check classroom_quality gate is_classroom = export_mode == "classroom" - if is_classroom: - from .storage import read_model as _rm - from .models import ClassroomQualityReport as _CQR - cqr = _rm(project_id, "classroom_quality_report.json", _CQR) - if cqr and cqr.state == "blocked" and not force: - raise PermissionError("Classroom quality gate blocked this export; pass force=true to proceed") spec_lock = read_json(project_id, "specs/spec_lock.json") or {} interaction_plan = read_json(project_id, "blueprints/interaction_plan.json") or {} @@ -115,6 +116,7 @@ def export_editable_pptx(project_id: str, force: bool = False, export_mode: str evidence_plan=evidence_plan, activity_plan=activity_plan, state_plan=state_plan, activity_bindings=activity_bindings, ) + _apply_teacher_plan_notes(deck_plan, _rj(project_id, "presentation/presentation_teacher_plan.json")) _wj(project_id, "blueprints/pptx_deck_plan.json", deck_plan.model_dump(mode="json")) struct_report = build_pptx_structure_report(deck_plan) _wj(project_id, "quality/pptx_structure_report.json", struct_report) @@ -183,6 +185,54 @@ def export_editable_pptx(project_id: str, force: bool = False, export_mode: str return export_path +def _assert_reconciled_production_contract(project_id: str) -> None: + required = ( + "presentation/presentation_content_plan.reconciled.json", + "quality/presentation_asset_reconciliation_report.json", + ) + root = ensure_project(project_id) + missing = [path for path in required if not (root / path).is_file()] + if missing: + raise PermissionError(f"Reconciled production artifacts are missing: {', '.join(missing)}") + report = read_json(project_id, "quality/presentation_asset_reconciliation_report.json") + if not isinstance(report, dict) or report.get("state") == "blocked": + raise PermissionError("Presentation asset reconciliation is blocked; PPTX export cannot proceed") + provenance = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + expected = provenance.get("reconciled_content_fingerprint") if isinstance(provenance, dict) else "" + if not expected or artifact_fingerprint(project_id, required[0]) != expected: + raise PermissionError("Reconciled presentation provenance is stale; PPTX export cannot proceed") + + +def _is_production_compatibility_blueprint(blueprint: LessonBlueprint) -> bool: + return bool( + blueprint.artifact_role == "legacy_compatibility" + and blueprint.canonical_source_artifact == "presentation/presentation_blueprint.json" + and blueprint.provenance_artifact == "presentation/legacy_blueprint_provenance.json" + ) + + +def _apply_teacher_plan_notes(deck_plan, payload: dict | None) -> None: + if not isinstance(payload, dict): + return + notes_by_slide: dict[int, list[str]] = {} + for item in payload.get("items", []): + if not isinstance(item, dict) or not item.get("target_legacy_slide_id"): + continue + slide_id = int(item["target_legacy_slide_id"]) + notes = notes_by_slide.setdefault(slide_id, []) + notes.extend([ + "Teacher observation:", + *[str(value) for value in item.get("observation_instructions", [])], + "Success criteria:", + *[str(value) for value in item.get("success_criteria", [])], + "Fallback/remediation:", + *[str(value) for value in item.get("fallback_remediation", [])], + ]) + for slide in deck_plan.slides: + if slide.slide_id in notes_by_slide: + slide.speaker_notes.extend(notes_by_slide[slide.slide_id]) + + def _new_master_presentation() -> Presentation: repository_root = Path(__file__).resolve().parents[4] master_path = repository_root / PROFILE.source @@ -258,6 +308,11 @@ def _render_component(slide, component, y: float) -> float: body = "\n".join(part for part in [data.get("audio_text", ""), choices, f"Answer: {data.get('answer', '')}"] if part) _add_activity_box(slide, title, body or "Listen and choose activity", 0.85, y, 6.4, 1.55) return y + 1.75 + if component.component_type == "ChoiceQuestion": + choices = "\n".join(f"{i + 1}. {choice}" for i, choice in enumerate(_list(data.get("choices")))) + body = "\n".join(part for part in [choices, f"Answer: {data.get('answer', '')}"] if part) + _add_activity_box(slide, title, body or "Choice activity", 0.85, y, 6.4, 1.55) + return y + 1.75 if component.component_type == "MatchGame": pairs = [pair for pair in _list(data.get("pairs")) if isinstance(pair, dict)] body = "\n".join(f"{pair.get('left', '')} ⟷ {pair.get('right', '')}" for pair in pairs) or "Match pairs" @@ -413,6 +468,8 @@ def _render_master_slide(slide, root: Path, source, deck, manifest: AssetManifes _master_objectives(slide, source, deck) elif layout == "two_card_contrast": _master_contrast(slide, root, source, deck, manifest) + elif layout == "choice_question": + _master_choice(slide, source, deck) elif layout == "listen_choose": _master_listen(slide, source, deck) elif layout == "match_pairs": @@ -540,6 +597,18 @@ def _master_listen(slide, source, deck) -> None: _add_text(slide, choice, x + 0.62, 3.48, card_width - 0.82, 0.62, 32, bold=True, color=_rgb(PROFILE.ink), center=True, font_name=PROFILE.chinese_font) +def _master_choice(slide, source, deck) -> None: + _master_header(slide, source.title or "选一选", _first_scaffold(source) or "Choose the best answer.") + component = next((c for c in source.components if c.component_type == "ChoiceQuestion"), None) + choices = [str(choice) for choice in (component.data.get("choices", []) if component else [])][:4] + card_width = (11.45 - max(0, len(choices) - 1) * 0.35) / max(1, len(choices)) + for index, choice in enumerate(choices): + x = 0.95 + index * (card_width + 0.35) + _master_card(slide, x, 2.35, card_width, 2.15, "FFFFFF") + _add_text(slide, chr(65 + index), x + 0.18, 2.58, 0.45, 0.42, 18, bold=True, color=_rgb(PROFILE.accent), center=True) + _add_text(slide, choice, x + 0.62, 3.05, card_width - 0.82, 0.68, 30, bold=True, color=_rgb(PROFILE.ink), center=True, font_name=PROFILE.chinese_font) + + def _master_match(slide, source, deck) -> None: _master_header(slide, source.title or "连一连", _first_scaffold(source) or "Match each greeting with its meaning.") component = next((c for c in source.components if c.component_type == "MatchGame"), None) @@ -613,7 +682,7 @@ def _first_scaffold(source) -> str: SUPPORTED_DECK_LAYOUTS: set[str] = { "cover_title", "objectives_cards", "single_item_focus", - "two_card_contrast", "listen_choose", "dialogue_bubbles", + "two_card_contrast", "choice_question", "listen_choose", "dialogue_bubbles", "match_pairs", "summary_cards", "generic_content", } diff --git a/apps/api/src/hcs_api/presentation_adapter_assessment.py b/apps/api/src/hcs_api/presentation_adapter_assessment.py index e2ff3e0..35f5313 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 ( @@ -35,6 +35,7 @@ "AudioButton", "VocabularyFlipCard", "SentenceDragBuilder", + "ChoiceQuestion", "ListenAndChoose", "MatchGame", "CharacterFormation", @@ -74,8 +75,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.", @@ -101,12 +103,17 @@ def _capability_for_mode(mode: str, units: list, registry: dict[str, dict[str, A if mode == "teacher_observation": return _capability(mode, unit_ids, "PracticeSlide", None, "teacher_only", [], [], True, False, True) if mode == "choice_response": + content_complete = all( + content_by_unit.get(unit.presentation_unit_id) + and content_item_is_complete(content_by_unit[unit.presentation_unit_id]) + for unit in units + ) return _capability( - mode, unit_ids, "PracticeSlide", "VocabularyFlipCard", "fallback", - registry.get("VocabularyFlipCard", {}).get("requires", []), - registry.get("VocabularyFlipCard", {}).get("optional", []), True, True, - "VocabularyFlipCard" in RENDERER_COMPONENT_TYPES, - ["No registered generic choice component exists; VocabularyFlipCard preserves learner content and trace, not choice scoring."], + mode, unit_ids, "PracticeSlide", "ChoiceQuestion", "exact" if content_complete else "unsupported", + registry.get("ChoiceQuestion", {}).get("requires", []), + registry.get("ChoiceQuestion", {}).get("optional", []), True, True, + "ChoiceQuestion" in RENDERER_COMPONENT_TYPES, + [] if content_complete else ["Canonical choice units do not carry the required ChoiceQuestion payload."], ) if mode == "guided_response": return _capability( @@ -118,6 +125,19 @@ def _capability_for_mode(mode: str, units: list, registry: dict[str, dict[str, A mode, unit_ids, "PracticeSlide", None, "approximate", [], [], True, True, True, ["PracticeSlide can present the planned role-play prompt but cannot implement role-play interaction."], ) + if mode == "character_formation": + content_complete = all( + content_by_unit.get(unit.presentation_unit_id) + and content_item_is_complete(content_by_unit[unit.presentation_unit_id]) + for unit in units + ) + return _capability( + mode, unit_ids, "ReadingSlide", "CharacterFormation", "exact" if content_complete else "unsupported", + registry.get("CharacterFormation", {}).get("requires", []), + registry.get("CharacterFormation", {}).get("optional", []), True, True, + "CharacterFormation" in RENDERER_COMPONENT_TYPES, + [] if content_complete else ["Canonical character units do not carry the required CharacterFormation payload."], + ) component = "ListenAndChoose" if mode == "listening_choice" else "MatchGame" content_complete = all( content_by_unit.get(unit.presentation_unit_id) and content_item_is_complete(content_by_unit[unit.presentation_unit_id]) @@ -211,6 +231,12 @@ def _safe_payload(component_type: str | None, unit, content_item) -> dict[str, A "audio_key": audio.asset_id if audio else "", "_shadow_trace": unit.trace.model_dump(mode="json"), } + if component_type == "ChoiceQuestion" and content_item: + return { + "choices": [item.text for item in content_item.options], + "answer": content_item.accepted_responses[0].normalized_value if content_item.accepted_responses else "", + "_shadow_trace": unit.trace.model_dump(mode="json"), + } if component_type == "MatchGame" and content_item: return { "pairs": [{"left": pair.left, "right": pair.right} for pair in content_item.matching_pairs], @@ -228,6 +254,7 @@ def _check_trace_coverage( report: PresentationAdapterAssessmentReport, canonical: CanonicalPresentationBlueprint, adapted: LessonBlueprint, + mapping=None, ) -> None: expected = { unit.presentation_unit_id @@ -235,12 +262,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_slide_id is not None } + 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 +283,7 @@ def _check_teacher_safety( report: PresentationAdapterAssessmentReport, canonical: CanonicalPresentationBlueprint, adapted: LessonBlueprint, + mapping=None, ) -> None: teacher_units = { unit.presentation_unit_id @@ -262,6 +296,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_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_bindings.py b/apps/api/src/hcs_api/presentation_bindings.py index b674751..f03eea2 100644 --- a/apps/api/src/hcs_api/presentation_bindings.py +++ b/apps/api/src/hcs_api/presentation_bindings.py @@ -22,8 +22,8 @@ UNSUITABLE_ZB_COMPONENTS = {"SentenceDragBuilder", "open_response", "role_play_scene", "OpenResponse", "RolePlayScene"} ACTIVITY_COMPONENT_HINTS = { - "scene_choice": {"ListenAndChoose", "ChoicePrompt", "VocabularyFlipCard", "GrammarContrast"}, - "multiple_choice": {"ListenAndChoose", "ChoicePrompt", "QuizCard"}, + "scene_choice": {"ChoiceQuestion", "ListenAndChoose", "ChoicePrompt", "VocabularyFlipCard", "GrammarContrast"}, + "multiple_choice": {"ChoiceQuestion", "ListenAndChoose", "ChoicePrompt", "QuizCard"}, "match_pairs": {"MatchGame", "MatchPairs"}, "listen_choose": {"ListenAndChoose", "DialoguePractice", "VocabularyFlipCard"}, "drag_sentence": {"SentenceDragBuilder"}, @@ -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,24 @@ 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) slide = slides.get(binding.slide_id) if not slide: 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_blueprint.py b/apps/api/src/hcs_api/presentation_blueprint.py index 6337a75..419efbd 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, @@ -100,18 +114,24 @@ def build_canonical_presentation_blueprint( """Create learner-safe presentation units from already planned activity bindings.""" evidence_by_id = {spec.evidence_id: spec for spec in evidence_plan.evidence_specs} activities = {activity.activity_id: activity for activity in activity_plan.activities} - units: list[PresentationUnit] = [] + activity_units: list[PresentationUnit] = [] for binding in bindings.bindings: activity = activities[binding.activity_id] evidence = [evidence_by_id[evidence_id] for evidence_id in binding.evidence_ids] learner_content = [] if binding.teacher_only else _target_items(evidence) - units.append( + activity_units.append( PresentationUnit( presentation_unit_id=binding.presentation_unit_id, binding_id=binding.id, activity_id=binding.activity_id, evidence_ids=list(binding.evidence_ids), unit_role=_unit_role(activity, binding.teacher_only), + title=_activity_title(binding.presentation_mode), + structural_role=( + "teacher_support" + if binding.teacher_only + else "learner_activity" if state_plan.route_hint else "" + ), learner_channel=list(binding.learner_channel), teacher_channel=list(binding.teacher_channel), presentation_mode=binding.presentation_mode, @@ -125,13 +145,19 @@ def build_canonical_presentation_blueprint( trace=binding.trace, ) ) + units = ( + _structural_units(state_plan, evidence_plan, activity_units) + if state_plan.route_hint + else activity_units + ) return CanonicalPresentationBlueprint( lesson_title=state_plan.lesson_title, presentation_units=units, 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.", ], ) @@ -174,6 +200,8 @@ def _binding_for_activity(activity: LearningActivity, evidence: list, inherited_ def _presentation_mode(activity: LearningActivity, evidence: list, teacher_only: bool) -> str: if teacher_only: return "teacher_observation" + if activity.activity_type == "character_formation": + return "character_formation" evidence_types = {item.evidence_type for item in evidence} if "listen_choose" in evidence_types: return "listening_choice" @@ -192,6 +220,85 @@ def _unit_role(activity: LearningActivity, teacher_only: bool) -> str: return "learner_interaction" if activity.learner_facing else "teacher_support" +def _activity_title(mode: str) -> str: + return { + "choice_response": "选择练习", + "listening_choice": "听音选择", + "matching_response": "匹配练习", + "guided_response": "引导回应", + "role_play_response": "角色练习", + "character_formation": "汉字练习", + "teacher_observation": "教师观察支持", + }.get(mode, "课堂练习") + + +def _structural_units( + state_plan: LearningStatePlan, + evidence_plan: EvidencePlan, + activity_units: list[PresentationUnit], +) -> list[PresentationUnit]: + """Add deterministic lesson scaffolding without inventing evidence.""" + target_items = list(dict.fromkeys( + item + for goal in state_plan.learning_goals + for item in goal.target_items + if item + ))[:8] + route_names = { + "greeting_lesson": "问候与礼貌表达", + "vocabulary_lesson": "核心词汇", + "dialogue_lesson": "对话与角色练习", + "character_lesson": "汉字与书写", + "grammar_pattern_lesson": "语法句型", + "mixed_lesson": "词汇、句型与互动练习", + } + route_label = route_names.get(state_plan.route_hint, "本课学习内容") + goals = [goal.description for goal in state_plan.learning_goals[:3] if goal.description] + modeled = target_items or goals or ["本课核心内容"] + + def structural( + role: str, + title: str, + content: list[str], + binding_id: str, + ) -> PresentationUnit: + unit_id = f"unit_structural_{role}" + activity_id = f"structural_{role}" + trace = PresentationTrace( + presentation_unit_id=unit_id, + binding_id=binding_id, + activity_id=activity_id, + evidence_ids=[], + ) + return PresentationUnit( + presentation_unit_id=unit_id, + binding_id=binding_id, + activity_id=activity_id, + evidence_ids=[], + unit_role="structural", + title=title, + structural_role=role, + learner_channel=["learner_display"], + teacher_channel=["speaker_notes"], + presentation_mode="choice_response", + learner_facing_content=content, + interaction_requirements=[], + fallback_mode="none", + media_requirements=[], + render_ready=True, + warnings=[], + trace=trace, + ) + + return [ + structural("lesson_opening", state_plan.lesson_title or "本课", [state_plan.lesson_title or "本课"], "structural_opening"), + structural("route_preview", "学习路线", [f"今天学习:{route_label}"], "structural_route_preview"), + structural("input_modeling", "输入与示范", ["先看、听、读本课内容:" + "、".join(modeled)], "structural_input_modeling"), + *activity_units, + structural("consolidation_summary", "课堂小结", ["回顾:" + "、".join(goals or modeled[:3])], "structural_consolidation"), + ] + + def _teacher_only(evidence) -> bool: return evidence.collection_method == "teacher_observation" or evidence.evidence_type == "teacher_observation" diff --git a/apps/api/src/hcs_api/presentation_content.py b/apps/api/src/hcs_api/presentation_content.py index a299d89..5d3a74e 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,18 @@ 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) + if getattr(unit, "unit_role", "") == "structural": + continue + 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 +80,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,16 +110,25 @@ 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) + if item.presentation_mode == "character_formation": + return bool(item.prompt and item.display_items) return item.complete @@ -140,7 +174,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 +228,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) @@ -199,10 +253,19 @@ def _content_for_unit(unit, evidence_by_id, activity_by_id, binding_by_id, langu item.prompt = item.prompt or "Practice the approved role-play prompt with a partner." item.learner_instructions = _role_play_instructions(activity, item.display_items) item.complete = bool(item.prompt and item.learner_instructions and item.display_items) + elif unit.presentation_mode == "character_formation": + item.prompt = item.prompt or "观察汉字结构并按顺序书写。" + item.learner_instructions = ["观察目标汉字。", "按示范顺序练习书写。"] + item.complete = bool(item.prompt and item.display_items) 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 +284,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 +324,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 +339,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 +351,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 +381,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 +416,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="", @@ -347,6 +447,8 @@ def _instructions_for_mode(mode: str, activity, display_items: list[str]) -> lis action = _learner_safe_text(activity.learner_action) if mode in {"choice_response", "listening_choice"}: return [action or "Choose the approved response."] + if mode == "character_formation": + return [action or "Observe the target character and practice its formation."] if mode == "guided_response": return [action or "Respond using the approved target language."] if mode == "matching_response": diff --git a/apps/api/src/hcs_api/presentation_eligibility.py b/apps/api/src/hcs_api/presentation_eligibility.py new file mode 100644 index 0000000..ffad027 --- /dev/null +++ b/apps/api/src/hcs_api/presentation_eligibility.py @@ -0,0 +1,209 @@ +"""Mandatory production eligibility checks for State-Evidence presentation output.""" + +from __future__ import annotations + +from .blueprint_compatibility import MODE_ADAPTER_MATRIX +from .models import ( + ActivityPlan, + CanonicalPresentationBlueprint, + EvidencePlan, + LearningStatePlan, + PresentationTeacherPlan, + ProductionPresentationEligibilityReport, + TeachingCandidates, +) + + +REQUIRED_STRUCTURAL_ROLES = ( + "lesson_opening", + "route_preview", + "input_modeling", + "learner_activity", + "consolidation_summary", +) + +SUPPORTED_ROUTES = { + "greeting_lesson", + "vocabulary_lesson", + "dialogue_lesson", + "character_lesson", + "grammar_pattern_lesson", + "mixed_lesson", +} + +# CharacterFormation remains a compatibility projection, but its current +# ``parts=list(character)`` payload is not a source-grounded formation +# contract. Keep it out of production eligibility until stroke/structure +# provenance is modeled upstream. +SUPPORTED_PRESENTATION_MODES = frozenset(MODE_ADAPTER_MATRIX) - {"character_formation"} + +ROUTE_MARKERS = { + "greeting_lesson": ("greeting", "问候", "你好", "您好"), + "vocabulary_lesson": ("vocabulary", "词汇", "词语", "生词"), + "dialogue_lesson": ("dialogue", "对话", "角色", "role_play"), + "character_lesson": ("character", "汉字", "笔画", "笔顺", "书写"), + "grammar_pattern_lesson": ("grammar", "语法", "句型", "pattern", "句式"), +} + +ROUTE_REQUIRED_MODES = { + "greeting_lesson": {"choice_response"}, + "vocabulary_lesson": {"choice_response"}, + "dialogue_lesson": {"role_play_response"}, + "character_lesson": {"character_formation"}, + "grammar_pattern_lesson": {"guided_response"}, +} +ROUTE_MODE_ALTERNATIVES = { + "vocabulary_lesson": ({"guided_response", "listening_choice"},), +} + + +def evaluate_production_presentation_eligibility( + state_plan: LearningStatePlan, + evidence_plan: EvidencePlan, + activity_plan: ActivityPlan, + canonical: CanonicalPresentationBlueprint, + teacher_plan: PresentationTeacherPlan, + candidates: TeachingCandidates | None = None, +) -> ProductionPresentationEligibilityReport: + """Block a route before production output if its semantics cannot be preserved.""" + route = state_plan.route_hint or "" + report = ProductionPresentationEligibilityReport( + route=route, + required_structural_roles=list(REQUIRED_STRUCTURAL_ROLES), + source_artifacts=[ + "learning/learning_state_plan.json", + "learning/evidence_plan.json", + "learning/activity_plan.json", + "quality/evidence_alignment_report.json", + "presentation/presentation_blueprint.json", + "presentation/presentation_teacher_plan.json", + ], + ) + report.route_supported = route in SUPPORTED_ROUTES + if not report.route_supported: + _block(report, "PRODUCTION_ROUTE_UNSUPPORTED", f"Route '{route or 'unknown'}' has no production State-Evidence compiler support.") + if route == "character_lesson": + _block( + report, + "PRODUCTION_CHARACTER_FORMATION_CONTRACT_UNAVAILABLE", + "Character lessons require a source-grounded stroke/formation contract before production export.", + ) + + text = " ".join( + value + for goal in state_plan.learning_goals + for value in [goal.goal_id, goal.description, goal.expected_behavior, *goal.target_items] + if value + ) + normalized = text.lower() + goals_have_refs = bool(state_plan.learning_goals) and all( + any(spec.goal_id == goal.goal_id for spec in evidence_plan.evidence_specs) + and any(set(spec.evidence_id for spec in evidence_plan.evidence_specs if spec.goal_id == goal.goal_id) & set(activity.evidence_ids) + for activity in activity_plan.activities) + for goal in state_plan.learning_goals + ) + route_goals_covered = True + if route in ROUTE_MARKERS: + route_goals_covered = any(marker.lower() in normalized for marker in ROUTE_MARKERS[route]) + elif route == "mixed_lesson": + route_goals_covered = len(state_plan.learning_goals) >= 2 and len( + {category for category in ("vocabulary", "dialogue", "character", "grammar", "guided") if category in normalized} + ) >= 2 + report.goals_covered = goals_have_refs and route_goals_covered + if not goals_have_refs: + report.goal_findings.append("Every learning goal must have evidence and an activity reference.") + if not route_goals_covered: + report.goal_findings.append(f"Goals do not cover the core semantic content for route '{route}'.") + if not report.goals_covered: + _block(report, "PRODUCTION_GOAL_COVERAGE_INCOMPLETE", *report.goal_findings) + if candidates is not None: + missing_source_semantics = { + "greeting_lesson": not candidates.core_vocabulary, + "vocabulary_lesson": not candidates.core_vocabulary, + "dialogue_lesson": not candidates.dialogue_candidates, + "character_lesson": not candidates.character_candidates, + "grammar_pattern_lesson": not candidates.grammar_candidates, + "mixed_lesson": not ( + candidates.core_vocabulary + or candidates.dialogue_candidates + or candidates.grammar_candidates + or candidates.character_candidates + ), + }.get(route, False) + if missing_source_semantics: + _block( + report, + "PRODUCTION_GOAL_COVERAGE_INCOMPLETE", + f"Route '{route}' has no source-grounded core teaching candidates; placeholder content is not production-eligible.", + ) + + learner_modes = { + unit.presentation_mode + for unit in canonical.presentation_units + if unit.unit_role == "learner_interaction" + } + report.supported_presentation_modes = sorted(learner_modes & SUPPORTED_PRESENTATION_MODES) + report.unsupported_presentation_modes = sorted(learner_modes - SUPPORTED_PRESENTATION_MODES) + report.required_presentation_modes = sorted(ROUTE_REQUIRED_MODES.get(route, set())) + report.missing_required_presentation_modes = sorted( + set(report.required_presentation_modes) - learner_modes + ) + if route in ROUTE_MODE_ALTERNATIVES: + report.missing_required_presentation_modes.extend( + f"one of {{{', '.join(sorted(alternative))}}}" + for alternative in ROUTE_MODE_ALTERNATIVES[route] + if not learner_modes.intersection(alternative) + ) + if route == "mixed_lesson" and len(learner_modes) < 2: + report.missing_required_presentation_modes = ["at least two route-preserving learner modes"] + report.presentation_modes_supported = ( + not report.unsupported_presentation_modes + and not report.missing_required_presentation_modes + and bool(learner_modes) + ) + if not report.presentation_modes_supported: + _block( + report, + "PRODUCTION_PRESENTATION_MODE_UNSUPPORTED", + "Canonical learner modes are not all implemented by the production adapter: " + f"unsupported={report.unsupported_presentation_modes or ['none']}, " + f"missing_required={report.missing_required_presentation_modes or ['none']}", + ) + + report.present_structural_roles = sorted({unit.structural_role for unit in canonical.presentation_units if unit.structural_role}) + report.missing_structural_roles = sorted(set(REQUIRED_STRUCTURAL_ROLES) - set(report.present_structural_roles)) + report.structural_roles_complete = not report.missing_structural_roles + if not report.structural_roles_complete: + _block( + report, + "PRODUCTION_STRUCTURAL_ROLE_MISSING", + f"Canonical presentation is missing structural roles: {', '.join(report.missing_structural_roles)}.", + ) + + teacher_units = [unit for unit in canonical.presentation_units if unit.teacher_channel_reference] + plan_by_unit = {item.target_presentation_unit_id: item for item in teacher_plan.items} + report.teacher_channel_resolved = True + for unit in teacher_units: + item = plan_by_unit.get(unit.presentation_unit_id) + if not item or not item.activity_id or not item.evidence_ids or not item.observation_instructions or not item.success_criteria or not item.fallback_remediation: + report.teacher_channel_resolved = False + report.teacher_channel_findings.append( + f"Teacher-only unit '{unit.presentation_unit_id}' has no complete teacher-channel output target." + ) + if teacher_plan.state == "blocked" or not report.teacher_channel_resolved: + _block( + report, + "PRODUCTION_TEACHER_CHANNEL_UNRESOLVED", + *report.teacher_channel_findings, + ) + + report.state = "blocked" if report.blocking else "warning" if report.warnings else "pass" + return report + + +def _block(report: ProductionPresentationEligibilityReport, code: str, *messages: str) -> None: + if code not in report.error_codes: + report.error_codes.append(code) + for message in messages: + if message and message not in report.blocking: + report.blocking.append(message) 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..7395877 100644 --- a/apps/api/src/hcs_api/presentation_parity.py +++ b/apps/api/src/hcs_api/presentation_parity.py @@ -33,6 +33,7 @@ "matching_response", "guided_response", "role_play_response", + "character_formation", "teacher_observation", } KERNEL_OWNED_KEYS = { @@ -140,11 +141,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/presentation_readiness.py b/apps/api/src/hcs_api/presentation_readiness.py index 0773039..aeca38f 100644 --- a/apps/api/src/hcs_api/presentation_readiness.py +++ b/apps/api/src/hcs_api/presentation_readiness.py @@ -82,7 +82,7 @@ def check_presentation_readiness( if not slide: _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/presentation_teacher.py b/apps/api/src/hcs_api/presentation_teacher.py new file mode 100644 index 0000000..a14bea7 --- /dev/null +++ b/apps/api/src/hcs_api/presentation_teacher.py @@ -0,0 +1,80 @@ +"""Build the non-learner-facing teacher delivery contract.""" + +from __future__ import annotations + +from .models import ( + ActivityPlan, + CanonicalPresentationBlueprint, + EvidencePlan, + PresentationTeacherPlan, + PresentationTeacherPlanItem, +) + + +def build_presentation_teacher_plan( + canonical: CanonicalPresentationBlueprint, + evidence_plan: EvidencePlan, + activity_plan: ActivityPlan, + mapping_plan=None, +) -> PresentationTeacherPlan: + evidence_by_id = {item.evidence_id: item for item in evidence_plan.evidence_specs} + activity_by_id = {item.activity_id: item for item in activity_plan.activities} + mapping_by_unit = {item.presentation_unit_id: item for item in (mapping_plan.mappings if mapping_plan else [])} + items: list[PresentationTeacherPlanItem] = [] + blocking: list[str] = [] + + for unit in canonical.presentation_units: + if not unit.teacher_channel_reference: + continue + activity = activity_by_id.get(unit.activity_id) + evidence = [evidence_by_id.get(item) for item in unit.evidence_ids] + evidence = [item for item in evidence if item is not None] + mapping = mapping_by_unit.get(unit.presentation_unit_id) + if activity is None or len(evidence) != len(unit.evidence_ids): + blocking.append(f"Teacher-only unit '{unit.presentation_unit_id}' is missing its approved activity/evidence source.") + continue + observation = [ + item.teacher_observation_notes + for item in evidence + if item.teacher_observation_notes + ] or [activity.teacher_action or "观察学生回应并记录证据。"] + success = [ + f"观察到:{item.observable_behavior or item.expected_behavior.get('target_language', item.target_items)}" + for item in evidence + ] + fallback = [activity.fallback_activity] if activity.fallback_activity else [] + for item in evidence: + action = item.failure_action.get("remediation_type") if isinstance(item.failure_action, dict) else "" + if action: + fallback.append(f"根据失败动作进行:{action}。") + if not success: + blocking.append(f"Teacher-only unit '{unit.presentation_unit_id}' has no success criteria.") + if not fallback: + blocking.append(f"Teacher-only unit '{unit.presentation_unit_id}' has no fallback/remediation instruction.") + items.append( + PresentationTeacherPlanItem( + id=f"teacher_plan_{unit.activity_id}", + activity_id=unit.activity_id, + evidence_ids=list(unit.evidence_ids), + observation_instructions=list(dict.fromkeys(observation)), + success_criteria=list(dict.fromkeys(success)), + fallback_remediation=list(dict.fromkeys(fallback)), + target_presentation_unit_id=unit.presentation_unit_id, + target_legacy_slide_id=mapping.legacy_slide_id if mapping else None, + target_legacy_component_id=mapping.legacy_component_id if mapping else None, + trace=unit.trace, + ) + ) + + return PresentationTeacherPlan( + state="blocked" if blocking else "pass", + items=items, + source_artifacts=[ + "learning/evidence_plan.json", + "learning/activity_plan.json", + "presentation/presentation_blueprint.json", + "presentation/legacy_component_mapping.json", + ], + blocking=blocking, + ) + 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 diff --git a/apps/api/src/hcs_api/quality.py b/apps/api/src/hcs_api/quality.py index 4c07c9f..a105b05 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: @@ -104,6 +106,15 @@ def check_quality(project_root: Path, blueprint: LessonBlueprint, manifest: Asse msg = f"{label} 听音选择缺少音频 {comp_audio}" report.missing_audio.append(msg) _block(report, msg) + if component.component_type == "ChoiceQuestion": + if not component.data.get("answer") or not component.data.get("choices"): + msg = f"{label} 选择题缺少选项或答案" + report.invalid_interactions.append(msg) + _block(report, msg) + elif component.data.get("answer") not in component.data.get("choices", []): + msg = f"{label} 选择题答案不在选项中" + report.invalid_interactions.append(msg) + _block(report, msg) if component.component_type == "MatchGame": if not component.data.get("pairs"): msg = f"{label} 连线匹配缺少配对数据" diff --git a/apps/api/src/hcs_api/realization_engine.py b/apps/api/src/hcs_api/realization_engine.py index 2e45d1b..03a2ce5 100644 --- a/apps/api/src/hcs_api/realization_engine.py +++ b/apps/api/src/hcs_api/realization_engine.py @@ -118,6 +118,8 @@ def _safe_activity_type(slide: LessonSlide, policy: ActivityPolicy) -> str: ct = c.component_type if ct == "VocabularyFlipCard": return "choose" + if ct == "ChoiceQuestion": + return "choose" if ct == "ListenAndChoose": return "listen_choose" if ct == "MatchGame": diff --git a/apps/api/src/hcs_api/renderer.py b/apps/api/src/hcs_api/renderer.py index 1b4386d..32f7046 100644 --- a/apps/api/src/hcs_api/renderer.py +++ b/apps/api/src/hcs_api/renderer.py @@ -24,11 +24,16 @@ def render_lesson( typography = resolve_typography(profile.target_language, profile.explanation_language, profile.transliteration_system, profile.interface_language) image_by_id = {asset.id: f"../{asset.path}" for asset in manifest.images} audio_by_id = {asset.id: f"../{asset.path}" for asset in manifest.audio} - slides_html = "\n".join(_render_slide(slide, image_by_id, audio_by_id, render_mode) for slide in blueprint.slides) + visible_slides = [ + slide for slide in blueprint.slides + if render_mode == "diagnostic" or not slide.teacher_only + ] + render_blueprint = blueprint.model_copy(update={"slides": visible_slides}) + slides_html = "\n".join(_render_slide(slide, image_by_id, audio_by_id, render_mode) for slide in visible_slides) 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, render_blueprint, report, is_classroom, activity_bindings, render_mode) title_label = escape(profile.scaffolding_language) if not is_classroom else "辅助语言" html = f""" @@ -45,7 +50,7 @@ def render_lesson(
@@ -66,7 +71,7 @@ def render_lesson(
@@ -111,15 +116,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): @@ -303,6 +326,16 @@ def _scaffold_text(text: str) -> str: {audio} {empty}
{choices}

+""" + if component.component_type == "ChoiceQuestion": + choices_list = [str(choice) for choice in _list(data.get("choices"))] + choices = "".join(f'' for choice in choices_list) + empty = _component_empty("暂无选择项") if not choices_list else "" + return f"""
+

{title}

+

{escape(_hint)}

+ {empty}
{choices}
+

""" if component.component_type == "MatchGame": pairs = [pair for pair in _list(data.get("pairs")) if isinstance(pair, dict)] @@ -332,7 +365,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 "" @@ -559,13 +592,13 @@ def _js() -> str: Array.from(builder.querySelectorAll('.drop-zone .word-chip')).forEach((item) => bank.appendChild(item)); builder.querySelector('.feedback').textContent = ''; } - const choice = event.target.closest('.listen-choose .choice'); + const choice = event.target.closest('.listen-choose .choice, .choice-question .choice'); if (choice) { - const host = choice.closest('.listen-choose'); + const host = choice.closest('.listen-choose, .choice-question'); const correct = choice.textContent.trim() === host.dataset.answer; choice.classList.toggle('correct', correct); choice.classList.toggle('incorrect', !correct); - host.querySelector('.feedback').textContent = correct ? '回答正确。' : '再听一次。'; + host.querySelector('.feedback').textContent = correct ? '回答正确。' : (host.classList.contains('listen-choose') ? '再听一次。' : '再试一次。'); } const matchButton = event.target.closest('.match-game button'); if (matchButton && !matchButton.classList.contains('matched')) { diff --git a/apps/api/src/hcs_api/review_agent.py b/apps/api/src/hcs_api/review_agent.py index 8f61dcb..7b830b4 100644 --- a/apps/api/src/hcs_api/review_agent.py +++ b/apps/api/src/hcs_api/review_agent.py @@ -31,7 +31,7 @@ "Teacher answer", "答案提示", "拖拽组句", "连一连", "排序", "判断", "归类", "分类", } -ALLOWED_ACTIVITIES_ZB = {"AudioButton", "VocabularyFlipCard", "ListenAndChoose", "MatchGame"} +ALLOWED_ACTIVITIES_ZB = {"AudioButton", "VocabularyFlipCard", "ChoiceQuestion", "ListenAndChoose", "MatchGame"} def review_blueprint( diff --git a/apps/api/src/hcs_api/storage.py b/apps/api/src/hcs_api/storage.py index 1a92cd0..5df6e64 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,11 +87,16 @@ "presentation/binding_quality_report.json", "presentation/abstract_activity_bindings.json", "presentation/presentation_blueprint.json", - "presentation/legacy_blueprint_from_v2.shadow.json", - "presentation/legacy_component_mapping.shadow.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", + "presentation/presentation_teacher_plan.json", + "quality/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_report.json", + "presentation/legacy_blueprint_from_v2.shadow.json", + "presentation/legacy_component_mapping.shadow.json", "presentation/presentation_media_asset_links.shadow.json", "presentation/presentation_media_projection_links.shadow.json", ], @@ -109,12 +115,14 @@ "quality": [ "quality/evidence_alignment_report.json", "quality/presentation_readiness_report.json", + "quality/production_presentation_eligibility_report.json", "quality/presentation_shadow_report.json", "quality/presentation_parity_report.json", "quality/presentation_adapter_assessment_report.json", "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", @@ -124,6 +132,32 @@ "agent": ["agent/AGENT_TASK.md", "agent/AGENT_RULES.md"], } +CORE_RELEASE_QUALITY_REPORTS = ( + ("Evidence alignment", "quality/evidence_alignment_report.json"), + ("Presentation readiness", "quality/presentation_readiness_report.json"), + ("Presentation binding", "presentation/binding_quality_report.json"), + ("Quality", "quality/quality_report.json"), +) + +# Existing reports outside this list are diagnostics. They may explain a +# release, but they do not silently become release authority. Every report in +# this list is production-owned: if present and blocked, no ZIP or PPTX may be +# emitted, including requests carrying the legacy ``force=true`` parameter. +OPTIONAL_RELEASE_QUALITY_REPORTS = ( + ("Presentation content", "quality/presentation_content_report.json"), + ("Presentation media requests", "quality/presentation_media_request_report.json"), + ("Canonical presentation", "quality/presentation_shadow_report.json"), + ("Production presentation eligibility", "quality/production_presentation_eligibility_report.json"), + ("Presentation asset reconciliation", "quality/presentation_asset_reconciliation_report.json"), + ("Presentation revision", "quality/presentation_revision_plan.json"), + ("Kernel revision", "quality/kernel_revision_plan.json"), + ("Classroom quality", "quality/classroom_quality_report.json"), + ("Comprehensibility", "quality/comprehensibility_report.json"), + ("Off-level content", "quality/off_level_report.json"), + ("Presentation realization", "quality/realization_report.json"), + ("Courseware review", "quality/courseware_review_report.json"), +) + def ensure_runtime() -> None: PROJECTS_DIR.mkdir(parents=True, exist_ok=True) @@ -191,6 +225,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 +352,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 +381,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 +397,54 @@ def _effective_stale_state( if legacy_reason not in reasons: reasons.append(legacy_reason) + if blueprint and _is_production_compatibility_blueprint(blueprint): + 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) + expected_canonical = provenance.get("canonical_blueprint_fingerprint") if isinstance(provenance, dict) else None + canonical_payload = read_json(project_id, "presentation/presentation_blueprint.json") + if expected_canonical and isinstance(canonical_payload, dict): + canonical_bytes = json.dumps( + canonical_payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + if hashlib.sha256(canonical_bytes).hexdigest() != expected_canonical: + stages.update({"presentation", "media", "render", "quality", "delivery"}) + reason = "Canonical presentation Blueprint changed after compatibility 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 +555,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, @@ -553,6 +653,51 @@ def _gate_status(project_id: str, relative_path: str) -> GateStatus: ) +def release_quality_gate_blockers( + project_id: str, + *, + required_reports: tuple[tuple[str, str], ...] = (), + include_core: bool = True, +) -> list[str]: + """Return one authoritative set of non-bypassable release blockers.""" + required_paths = {path for _label, path in required_reports} + reports = ( + (*CORE_RELEASE_QUALITY_REPORTS, *OPTIONAL_RELEASE_QUALITY_REPORTS) + if include_core + else OPTIONAL_RELEASE_QUALITY_REPORTS + ) + blockers: list[str] = [] + for label, relative_path in reports: + payload = read_json(project_id, relative_path) + if not isinstance(payload, dict): + if relative_path in required_paths: + blockers.append(f"{label} gate is missing") + continue + state = str(payload.get("state", "not_run")).lower() + if state in {"pass", "passed", "warning"}: + continue + details = payload.get("blocking_reasons", payload.get("blocking", [])) + if isinstance(details, list) and details: + blockers.extend(f"{label}: {detail}" for detail in details) + else: + blockers.append(f"{label} gate is {state}") + return list(dict.fromkeys(blockers)) + + +def assert_release_quality_gate( + project_id: str, + *, + required_reports: tuple[tuple[str, str], ...] = CORE_RELEASE_QUALITY_REPORTS, +) -> None: + """Stop every release surface when a production quality report blocks.""" + blockers = release_quality_gate_blockers( + project_id, + required_reports=required_reports, + ) + if blockers: + raise PermissionError("Release quality gate is blocked: " + "; ".join(blockers)) + + def _gate_summary( project_id: str, *, @@ -566,7 +711,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) @@ -578,8 +723,31 @@ def _gate_summary( technical_blockers: list[str] = [] root = project_dir(project_id) lesson_path = root / "courseware" / "lesson.html" + release_blockers = release_quality_gate_blockers(project_id, include_core=False) + technical_blockers.extend(release_blockers) 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 blueprint and _is_production_compatibility_blueprint(blueprint): + reconciled_path = root / "presentation/presentation_content_plan.reconciled.json" + reconciliation_path = root / "quality/presentation_asset_reconciliation_report.json" + if not reconciled_path.is_file(): + technical_blockers.append("Reconciled presentation content plan artifact is missing") + if not reconciliation_path.is_file(): + technical_blockers.append("Presentation asset reconciliation report is missing") + reconciliation = read_json(project_id, "quality/presentation_asset_reconciliation_report.json") + if not isinstance(reconciliation, dict) or reconciliation.get("state") == "blocked": + technical_blockers.append("Presentation asset reconciliation is blocked or missing") + provenance = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + expected_reconciled = provenance.get("reconciled_content_fingerprint") if isinstance(provenance, dict) else "" + if not expected_reconciled or artifact_fingerprint(project_id, "presentation/presentation_content_plan.reconciled.json") != expected_reconciled: + technical_blockers.append("Reconciled presentation provenance is stale") if (render_reason := _render_artifact_reason(lesson_path)) is not None: technical_blockers.append(render_reason) @@ -591,14 +759,14 @@ def _gate_summary( overall_state = "blocked" elif any(state == "running" for state in states): overall_state = "running" + elif release_blockers: + overall_state = "blocked" elif any(state == "warning" for state in states): overall_state = "warning" elif all(state == "not_run" for state in states): overall_state = "not_run" elif any(state == "not_run" for state in states): overall_state = "not_run" - elif technical_blockers: - overall_state = "blocked" elif all(state in {"passed", "warning"} for state in states): overall_state = "passed" else: @@ -607,17 +775,10 @@ def _gate_summary( blocking_reasons.extend(technical_blockers) warnings = [warning for gate in gates for warning in gate.warnings] blocked_or_stale = any(gate.state in {"blocked", "failed", "stale", "not_run", "running"} or gate.stale for gate in gates) - all_gates_run = all(gate.state in {"passed", "warning", "blocked"} for gate in gates) gates_passed = all(gate.state in {"passed", "warning"} for gate in gates) technical_ready = not technical_blockers export_allowed = bool(technical_ready and gates_passed and not stale and not blocked_or_stale) - force_export_allowed = bool( - technical_ready - and all_gates_run - and not stale - and not any(gate.state in {"failed", "stale", "running"} or gate.stale for gate in gates) - and any(gate.state in {"blocked", "warning"} for gate in gates) - ) + force_export_allowed = False return GateSummary( evidence_alignment=evidence, presentation_readiness=readiness, @@ -684,8 +845,15 @@ def _project_stages( gate_summary.presentation_readiness, gate_summary.presentation_binding, ) + eligibility_report = read_json(project_id, "quality/production_presentation_eligibility_report.json") + revision_report = read_json(project_id, "quality/presentation_revision_plan.json") + production_presentation_blocked = any( + isinstance(report, dict) and report.get("state") == "blocked" + for report in (eligibility_report, revision_report) + ) + presentation_gate_blocked = any(gate.state in {"blocked", "failed"} for gate in presentation_gates) if not blueprint: - presentation_state: StageState = "not_started" + presentation_state: StageState = "blocked" if production_presentation_blocked or presentation_gate_blocked else "not_started" elif any(gate.state == "stale" or gate.stale for gate in presentation_gates): presentation_state = "stale" elif any(gate.state in {"blocked", "failed"} for gate in presentation_gates): @@ -701,6 +869,8 @@ def _project_stages( for gate in presentation_gates for reason in gate.blocking_reasons ] + if production_presentation_blocked or presentation_gate_blocked: + presentation_blockers.extend(gate_summary.blocking_reasons) stages = [ StageStatus( stage_id="material", @@ -727,9 +897,22 @@ 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_content_plan.reconciled.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/presentation_teacher_plan.json", + "quality/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_report.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", @@ -750,13 +933,14 @@ def _project_stages( warnings=gate_summary.warnings, available_actions=( ["agent_package", "agent_validate"] - + (["export", "force_export"] if gate_summary.force_export_allowed else []) + + (["export"] if gate_summary.export_allowed else []) + + (["force_export"] if gate_summary.force_export_allowed else []) ), ), ] stale_aliases = { "profile": {"profile"}, - "design": {"design"}, + "design": {"design", "learning"}, "presentation": {"presentation", "media"}, "quality": {"render", "quality"}, "delivery": {"delivery"}, @@ -831,18 +1015,10 @@ def _artifact_type(relative: Path, path: Path) -> str: def zip_output(project_id: str, force: bool = False, classroom: bool = False) -> Path: root = ensure_project(project_id) - if force: - _assert_export_technical_artifacts(project_id, root) - _assert_export_gate_inputs(project_id, force=True) + _assert_export_gate_inputs(project_id) + _assert_export_technical_artifacts(project_id, root) alignment_report = read_json(project_id, "quality/evidence_alignment_report.json") or {} - if isinstance(alignment_report, dict) and alignment_report.get("state") == "blocked" and not force: - raise PermissionError("Evidence alignment gate is blocked; pass force=true to export anyway") readiness_report = read_json(project_id, "quality/presentation_readiness_report.json") or {} - if isinstance(readiness_report, dict) and readiness_report.get("state") == "blocked" and not force: - raise PermissionError("Presentation readiness gate is blocked; pass force=true to export anyway") - if not force: - _assert_export_gate_inputs(project_id, force=False) - _assert_export_technical_artifacts(project_id, root) blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) lesson_path = root / "courseware" / "lesson.html" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") @@ -893,12 +1069,26 @@ 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_content_plan.reconciled.json": "assets/data/presentation_content_plan.reconciled.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", + "presentation/presentation_teacher_plan.json": "assets/data/presentation_teacher_plan.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", "presentation/activity_bindings.json": "assets/data/activity_bindings.json", "presentation/binding_quality_report.json": "assets/data/binding_quality_report.json", "quality/presentation_readiness_report.json": "assets/data/presentation_readiness_report.json", + "quality/production_presentation_eligibility_report.json": "assets/data/production_presentation_eligibility_report.json", + "quality/presentation_asset_reconciliation_report.json": "assets/data/presentation_asset_reconciliation_report.json", "quality/quality_report.json": "assets/data/quality_report.json", "quality/quality_summary.md": "quality_summary.md", "exports/export_manifest.json": "export_manifest.json", @@ -911,8 +1101,30 @@ 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: + blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + if blueprint 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") + if _is_production_compatibility_blueprint(blueprint): + for path, label in ( + ("presentation/presentation_content_plan.reconciled.json", "Reconciled presentation content plan"), + ("quality/presentation_asset_reconciliation_report.json", "Presentation asset reconciliation report"), + ): + if not (root / path).is_file(): + raise PermissionError(f"{label} artifact is missing; export cannot proceed") + reconciliation = read_json(project_id, "quality/presentation_asset_reconciliation_report.json") + if not isinstance(reconciliation, dict) or reconciliation.get("state") == "blocked": + raise PermissionError("Presentation asset reconciliation is blocked or missing; export cannot proceed") + provenance = read_json(project_id, "presentation/legacy_blueprint_provenance.json") + expected = provenance.get("reconciled_content_fingerprint") if isinstance(provenance, dict) else "" + if not expected or artifact_fingerprint(project_id, "presentation/presentation_content_plan.reconciled.json") != expected: + raise PermissionError("Reconciled presentation provenance is stale; 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") @@ -934,20 +1146,13 @@ def _render_artifact_reason(path: Path) -> str | None: return None -def _assert_export_gate_inputs(project_id: str, *, force: bool) -> None: - gates = ( - ("Evidence alignment", "quality/evidence_alignment_report.json"), - ("Presentation readiness", "quality/presentation_readiness_report.json"), - ("Presentation binding", "presentation/binding_quality_report.json"), - ("Quality", "quality/quality_report.json"), +def _is_production_compatibility_blueprint(blueprint: LessonBlueprint) -> bool: + return bool( + blueprint.artifact_role == "legacy_compatibility" + and blueprint.canonical_source_artifact == "presentation/presentation_blueprint.json" + and blueprint.provenance_artifact == "presentation/legacy_blueprint_provenance.json" ) - allowed = {"pass", "passed", "warning", "blocked"} - for label, relative_path in gates: - payload = read_json(project_id, relative_path) - state = str(payload.get("state", "not_run")).lower() if isinstance(payload, dict) else "not_run" - if state not in allowed or (state == "blocked" and not force): - if state == "blocked" and not force: - raise PermissionError(f"{label} gate is blocked; pass force=true to export anyway") - if force: - raise PermissionError(f"{label} gate is {state}; force export is unavailable") - raise PermissionError(f"{label} gate is {state}; run the gate before export") + + +def _assert_export_gate_inputs(project_id: str) -> None: + assert_release_quality_gate(project_id) diff --git a/apps/api/src/hcs_api/strategist.py b/apps/api/src/hcs_api/strategist.py index c45ce2a..c69e3dc 100644 --- a/apps/api/src/hcs_api/strategist.py +++ b/apps/api/src/hcs_api/strategist.py @@ -128,7 +128,7 @@ def build_interaction_plan(blueprint: LessonBlueprint) -> dict[str, Any]: "slide_id": slide.id, "component_id": component.id, "component_type": component.component_type, - "requires_answer": component.component_type in {"SentenceDragBuilder", "ListenAndChoose"}, + "requires_answer": component.component_type in {"SentenceDragBuilder", "ChoiceQuestion", "ListenAndChoose"}, "requires_audio": component.component_type in {"ListenAndChoose", "AudioButton"}, } ) 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 diff --git a/apps/api/tests/test_api_routes.py b/apps/api/tests/test_api_routes.py index 2122a7c..f9896b0 100644 --- a/apps/api/tests/test_api_routes.py +++ b/apps/api/tests/test_api_routes.py @@ -12,11 +12,33 @@ import hcs_api.main as main import hcs_api.storage as storage from hcs_api.main import app -from hcs_api.models import AssetCandidate, AssetFile, AssetManifest, ContentBlock, ImageProviderSettings, LLMProviderSettings, LessonBlueprint, LessonProfile, LessonSlide, ProviderSettings, QualityReport, SlideComponent, SourceMaterial +from hcs_api.models import AssetCandidate, AssetFile, AssetManifest, ContentBlock, ImageProviderSettings, LLMProviderSettings, LessonBlueprint, LessonProfile, LessonSlide, ProviderSettings, QualityReport, SlideComponent, SourceMaterial, SourcePage, TextBlock from hcs_api.providers import ProviderError 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", + pages=[SourcePage( + page_number=1, + title="生词 词语 词汇 词卡", + text_blocks=[TextBlock(id="fixture", text="你好 你好 nǐ hǎo hello 谢谢 谢谢 xièxie thanks")], + )], + ), + ) + 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("/") @@ -285,7 +307,7 @@ def test_project_pipeline_route_runs_full_generation(tmp_path, monkeypatch) -> N body = pipeline_response.json() assert body["status"] == "rendered" assert body["route"] == "main-generation" - assert body["quality_state"] == "warning" + assert body["quality_state"] in {"pass", "warning"} summary_response = client.get(f"/api/projects/{project_id}/design/summary") assert summary_response.status_code == 200 summary = summary_response.json() @@ -294,9 +316,16 @@ def test_project_pipeline_route_runs_full_generation(tmp_path, monkeypatch) -> N assert summary["activity_plan"] is not None assert "available_actions" in summary assert body["lesson_blueprint"]["slides"] - assert body["asset_manifest"]["audio"] + assert body["asset_manifest"] is not None assert body["preview_url"] == f"/runtime/projects/{project_id}/courseware/lesson.html" assert body["export_url"] == f"/api/projects/{project_id}/export" + delivery_actions = next( + stage["available_actions"] + for stage in body["stages"] + if stage["stage_id"] == "delivery" + ) + assert "export" in delivery_actions + assert "force_export" not in delivery_actions assert (project_root / "specs" / "lesson_spec.md").exists() assert (project_root / "specs" / "spec_lock.json").exists() assert (project_root / "blueprints" / "lesson_blueprint.json").exists() @@ -355,7 +384,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 +432,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 +489,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 +605,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") @@ -567,7 +623,7 @@ def test_export_blocker_is_structured_and_carries_gate_summary(tmp_path, monkeyp assert detail["code"] == "export_gate_blocked" assert "missing evidence" in detail["blocking_reasons"] assert detail["gate_summary"]["quality_report"]["state"] == "blocked" - assert detail["force_export_allowed"] is True + assert detail["force_export_allowed"] is False def test_project_listing_and_profile_confirmation_are_persistent(tmp_path, monkeypatch) -> None: @@ -653,7 +709,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 +747,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,15 +806,27 @@ 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): + def fake_generate(_root, blueprint, *_args, **kwargs): captured["force_regenerate"] = bool(kwargs.get("force_regenerate")) - return AssetManifest() + audio = [ + AssetFile( + id=component.data["audio_key"], + kind="audio", + path=f"assets/audio/{component.data['audio_key']}.wav", + text=component.data.get("audio_text", ""), + ) + for slide in blueprint.slides + for component in slide.components + if component.component_type == "ListenAndChoose" and component.data.get("audio_key") + ] + return AssetManifest(audio=audio) 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,13 +840,24 @@ 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") storage.write_model( project_id, "asset_manifest.json", - AssetManifest(images=[AssetFile(id="hero", kind="image", path="assets/images/hero.svg", candidates=[candidate], review_state="pending_review")]), + AssetManifest( + images=[AssetFile(id="hero", kind="image", path="assets/images/hero.svg", candidates=[candidate], review_state="pending_review")], + audio=[ + AssetFile( + id=storage.read_json(project_id, "presentation/presentation_media_request_plan.json")["requests"][0]["id"], + kind="audio", + path="assets/audio/media-request.wav", + text="你好", + ) + ], + ), ) manifest_response = TestClient(app).get(f"/api/projects/{project_id}/media") @@ -809,10 +888,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 +958,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 +981,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"}, @@ -1119,7 +1201,7 @@ def test_editable_pptx_export_after_pipeline_keeps_html_zip_intact(tmp_path, mon assert "assets/data/quality_report.json" in names -def test_editable_pptx_export_respects_blocked_quality_and_force(tmp_path, monkeypatch) -> None: +def test_editable_pptx_export_rejects_blocked_quality_even_with_force(tmp_path, monkeypatch) -> None: runtime_dir = tmp_path / "runtime" projects_dir = runtime_dir / "projects" monkeypatch.setattr(storage, "RUNTIME_DIR", runtime_dir) @@ -1151,6 +1233,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) @@ -1158,13 +1249,11 @@ def test_editable_pptx_export_respects_blocked_quality_and_force(tmp_path, monke assert normal_response.status_code == 409 forced_response = client.post(f"/api/projects/{project_id}/export/pptx-editable?force=true") - assert forced_response.status_code == 200 - body = forced_response.json() - manifest = json.loads((projects_dir / project_id / "exports" / "pptx_export_manifest.json").read_text(encoding="utf-8")) - assert manifest["forced"] is True - assert "missing answer" in manifest["forced_blockers"] - assert manifest["force_confirmation"] == "explicit force=true request" - assert (projects_dir / project_id / "exports" / body["filename"]).exists() + assert forced_response.status_code == 409 + detail = forced_response.json()["detail"] + assert detail["code"] == "export_gate_blocked" + assert detail["force_export_allowed"] is False + assert not (projects_dir / project_id / "exports" / "pptx_export_manifest.json").exists() def test_agent_handoff_e2e_validates_then_render_exports(tmp_path, monkeypatch) -> None: @@ -1251,22 +1340,22 @@ 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" -def test_blocked_quality_prevents_normal_export_but_force_export_succeeds(tmp_path, monkeypatch) -> None: +def test_blocked_quality_prevents_normal_and_forced_export(tmp_path, monkeypatch) -> None: runtime_dir = tmp_path / "runtime" projects_dir = runtime_dir / "projects" monkeypatch.setattr(storage, "RUNTIME_DIR", runtime_dir) @@ -1279,6 +1368,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", @@ -1290,15 +1388,75 @@ def test_blocked_quality_prevents_normal_export_but_force_export_succeeds(tmp_pa assert normal_response.status_code == 409 forced_response = client.post(f"/api/projects/{project_id}/export?force=true") - assert forced_response.status_code == 200 - with zipfile.ZipFile(io.BytesIO(forced_response.content)) as zf: - names = set(zf.namelist()) - manifest = zf.read("export_manifest.json").decode("utf-8") - assert "lesson.html" in names - assert "assets/data/quality_report.json" in names - assert '"forced": true' in manifest - assert '"missing answer"' in manifest - assert '"force_confirmation": "explicit force=true request"' in manifest + assert forced_response.status_code == 409 + detail = forced_response.json()["detail"] + assert detail["code"] == "export_gate_blocked" + assert detail["force_export_allowed"] is False + assert storage.latest_export_path(project_id) is None + + +def test_optional_production_report_blocks_every_release_surface(tmp_path, monkeypatch) -> None: + runtime_dir = tmp_path / "runtime" + projects_dir = runtime_dir / "projects" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime_dir) + monkeypatch.setattr(storage, "PROJECTS_DIR", projects_dir) + monkeypatch.setattr(main, "PROJECTS_DIR", projects_dir) + project_id = "blocked-optional-release-report" + root = 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="cover", + title="发布门禁", + )], + ), + ) + (root / "courseware" / "lesson.html").write_text("ready", encoding="utf-8") + for relative in ( + "quality/evidence_alignment_report.json", + "quality/presentation_readiness_report.json", + "presentation/binding_quality_report.json", + ): + storage.write_json(project_id, relative, {"state": "pass"}) + storage.write_model( + project_id, + "quality_report.json", + QualityReport(state="warning", warnings=["non-blocking presentation note"]), + ) + 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_json( + project_id, + "quality/courseware_review_report.json", + {"state": "blocked", "blocking": ["teacher-channel review failed"]}, + ) + + client = TestClient(app) + assert client.get(f"/api/projects/{project_id}").json()["gate_summary"]["overall_state"] == "blocked" + responses = ( + client.get(f"/api/projects/{project_id}/export"), + client.post(f"/api/projects/{project_id}/export?force=true"), + client.post(f"/api/projects/{project_id}/export/pptx-editable"), + client.post(f"/api/projects/{project_id}/export/pptx-editable?force=true"), + ) + assert all(response.status_code == 409 for response in responses) + assert all( + "teacher-channel review failed" in " ".join(response.json()["detail"]["blocking_reasons"]) + for response in responses + ) + assert not list((root / "exports").iterdir()) def test_component_registry_route_exposes_supported_components() -> None: @@ -1307,6 +1465,7 @@ def test_component_registry_route_exposes_supported_components() -> None: assert response.status_code == 200 body = response.json() assert "VocabularyFlipCard" in body + assert "ChoiceQuestion" in body assert body["ClassroomGame"]["experimental"] is True diff --git a/apps/api/tests/test_codex_bridge.py b/apps/api/tests/test_codex_bridge.py index 3155364..4750794 100644 --- a/apps/api/tests/test_codex_bridge.py +++ b/apps/api/tests/test_codex_bridge.py @@ -9,8 +9,9 @@ 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 +from hcs_api.models import LessonBlueprint, LessonProfile, LessonSlide, MediaRequirements, SourceMaterial, SourcePage, TextBlock TOKEN = "codex-bridge-test-token" @@ -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() @@ -90,6 +94,11 @@ def test_blueprint_job_is_schema_validated_and_consumed_on_retry(tmp_path: Path, storage.ensure_project(project_id) storage.write_model(project_id, "source_material.json", SourceMaterial( original_filename="lesson.pdf", source_type="pdf", title="你好", + pages=[SourcePage( + page_number=1, + title="问候词汇", + text_blocks=[TextBlock(id="fixture", text="词汇 你好 您好")], + )], )) storage.write_model(project_id, "lesson_profile.json", LessonProfile( lesson_title="你好", learner_level="Beginner", target_audience="Adults", @@ -98,33 +107,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 +120,29 @@ 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="你好", + pages=[SourcePage( + page_number=1, + title="生词 词语 词汇 词卡", + text_blocks=[TextBlock(id="fixture", text="你好 你好 nǐ hǎo hello 谢谢 谢谢 xièxie thanks")], )], )) + 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..49a86a3 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( @@ -300,7 +303,9 @@ def test_phase2b_teacher_content_never_enters_learner_payload() -> None: assert report.state in {"pass", "warning"} assert content.content_items[0].prompt == "" assert content.content_items[0].display_items == [] - assert adapted.slides == [] + assert len(adapted.slides) == 1 + assert adapted.slides[0].teacher_only is True + assert adapted.slides[0].title == "教师支持" assert "private rubric" not in json.dumps(adapted.model_dump(mode="json")).lower() diff --git a/apps/api/tests/test_pipeline.py b/apps/api/tests/test_pipeline.py index 2f59df6..553e910 100644 --- a/apps/api/tests/test_pipeline.py +++ b/apps/api/tests/test_pipeline.py @@ -31,7 +31,9 @@ from hcs_api.pipeline import ( generate_lesson_blueprint, generate_project_media, + finalize_production_media_contract, render_and_check, + run_blueprint_stage, write_blueprint_artifacts, write_presentation_bindings, write_spec_artifacts, @@ -52,7 +54,7 @@ def test_zip_output_respects_blocked_evidence_alignment(tmp_path: Path, monkeypa with pytest.raises(PermissionError, match="Evidence alignment gate"): zip_output(project_id) - with pytest.raises(PermissionError, match="Blueprint artifact is missing"): + with pytest.raises(PermissionError, match="Evidence alignment gate"): zip_output(project_id, force=True) @@ -66,14 +68,20 @@ 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": []}) + finalization = finalize_production_media_contract(project_id, manifest) + assert finalization.state == "pass" + blueprint = finalization.legacy + assert blueprint is not None report = render_and_check(project_id, project_root, profile, blueprint, manifest) for relative in ( "quality/evidence_alignment_report.json", @@ -86,8 +94,8 @@ def test_pptx_to_offline_zip(tmp_path: Path, monkeypatch) -> None: assert source.pages[0].title == "第14课 我在学习中文呢" assert blueprint.slides - assert manifest.audio - assert report.state == "warning" + assert manifest is not None + assert report.state in {"pass", "warning"} assert html_path.exists() html = html_path.read_text(encoding="utf-8") assert 'class="slide-frame"' in html @@ -115,8 +123,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) - assert any(name.startswith("assets/audio/") for name in names) + # Media is emitted only when the canonical evidence/content contract asks + # for it; this grammar fixture does not fabricate an audio requirement. + assert not any(name.startswith("assets/audio/") for name in names) assert export_manifest["forced"] is False @@ -295,6 +304,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 封面" @@ -938,15 +948,14 @@ def test_classroom_pptx_rejects_blocked_qa(tmp_path: Path, monkeypatch) -> None: assert stored_cqr is not None, "ClassroomQualityReport not found in storage" assert stored_cqr.state == "blocked", f"Expected blocked, got {stored_cqr.state}" import pytest - with pytest.raises(PermissionError, match="Classroom quality gate blocked"): + with pytest.raises(PermissionError, match="Release quality gate is blocked: Classroom quality"): export_editable_pptx(project_id, export_mode="classroom") - # Force export should work and produce Diagnostic file - path = export_editable_pptx(project_id, force=True, export_mode="classroom") - assert "Diagnostic" in path.name + with pytest.raises(PermissionError, match="Release quality gate is blocked: Classroom quality"): + export_editable_pptx(project_id, force=True, export_mode="classroom") -def test_classroom_pptx_force_creates_diagnostic_manifest(tmp_path: Path, monkeypatch) -> None: - """Forced classroom PPTX export manifest should have diagnostic=true.""" +def test_classroom_pptx_green_export_manifest_is_not_diagnostic(tmp_path: Path, monkeypatch) -> None: + """A green classroom PPTX export remains an ordinary editable delivery.""" monkeypatch.setattr("hcs_api.storage.RUNTIME_DIR", tmp_path / "runtime") monkeypatch.setattr("hcs_api.storage.PROJECTS_DIR", tmp_path / "runtime" / "projects") project_id = "force_diag" diff --git a/apps/api/tests/test_presentation_adapter_assessment.py b/apps/api/tests/test_presentation_adapter_assessment.py index c3620bc..fd20d1c 100644 --- a/apps/api/tests/test_presentation_adapter_assessment.py +++ b/apps/api/tests/test_presentation_adapter_assessment.py @@ -17,6 +17,7 @@ LearningActivity, LearningGoal, LearningStatePlan, + LanguageItem, LessonSlide, SlideComponent, ) @@ -29,6 +30,7 @@ run_presentation_adapter_assessment, ) from hcs_api.presentation_blueprint import compile_shadow_presentation +from hcs_api.presentation_content import attach_content_references, build_presentation_content_plan def _write_inputs(tmp_path: Path, monkeypatch, *, evidence_type: str = "deterministic_choice", teacher_only: bool = False): @@ -50,15 +52,29 @@ def _write_inputs(tmp_path: Path, monkeypatch, *, evidence_type: str = "determin evidence_ids=["ev_1"], activity_type="teacher_observation" if teacher_only else "scene_choice", output_type="teacher_notes" if teacher_only else "selection", + learner_action="" if teacher_only else "Choose the approved response.", learner_facing=not teacher_only, ) bindings, canonical, shadow = compile_shadow_presentation( state, EvidencePlan(evidence_specs=[evidence]), ActivityPlan(activities=[activity]), EvidenceAlignmentReport(), ) assert canonical is not None + content, _ = build_presentation_content_plan( + state, + EvidencePlan(evidence_specs=[evidence]), + ActivityPlan(activities=[activity]), + bindings, + canonical, + [ + LanguageItem(id="lang_nihao", target_form="你好", scaffold_meaning="hello"), + LanguageItem(id="lang_ninhao", target_form="您好", scaffold_meaning="hello (polite)"), + ], + ) + canonical = attach_content_references(canonical, content) storage.write_json(project_id, ABSTRACT_BINDING_PATH, bindings.model_dump(mode="json", by_alias=True)) storage.write_json(project_id, CANONICAL_BLUEPRINT_PATH, canonical.model_dump(mode="json", by_alias=True)) storage.write_json(project_id, SHADOW_REPORT_PATH, shadow.model_dump(mode="json", by_alias=True)) + storage.write_json(project_id, "presentation/presentation_content_plan.json", content.model_dump(mode="json", by_alias=True)) return project_id, root, canonical @@ -77,9 +93,9 @@ def test_adapter_assessment_maps_known_presentation_modes(tmp_path: Path, monkey report = run_presentation_adapter_assessment(project_id) mapping = json.loads((root / MAPPING_PLAN_PATH).read_text(encoding="utf-8"))["capabilities"] - assert report.fallback_mappings_count == 1 + assert report.exact_mappings_count == 1 assert mapping[0]["presentation_mode"] == "choice_response" - assert mapping[0]["recommended_legacy_component_type"] == "VocabularyFlipCard" + assert mapping[0]["recommended_legacy_component_type"] == "ChoiceQuestion" def test_adapter_assessment_flags_unsupported_learner_mode(tmp_path: Path, monkeypatch) -> None: @@ -125,13 +141,14 @@ def leaking_adapter(blueprint): assert report.teacher_channel_findings -def test_adapter_assessment_warns_on_fallback_mapping(tmp_path: Path, monkeypatch) -> None: +def test_adapter_assessment_reports_exact_choice_mapping(tmp_path: Path, monkeypatch) -> None: project_id, _, _ = _write_inputs(tmp_path, monkeypatch) report = run_presentation_adapter_assessment(project_id) assert report.state == "warning" - assert report.fallback_modes == ["choice_response"] + assert report.fallback_modes == [] + assert report.exact_mappings_count == 1 def test_adapter_assessment_does_not_require_renderer_changes() -> None: @@ -153,7 +170,7 @@ def test_adapter_payload_requirements_are_validated(tmp_path: Path, monkeypatch) report = run_presentation_adapter_assessment(project_id) - assert any("choices, answer, audio_key" in finding for finding in report.component_payload_findings) + assert any("audio_key" in finding for finding in report.component_payload_findings) def test_teacher_only_units_are_not_mapped_to_learner_components(tmp_path: Path, monkeypatch) -> None: @@ -173,7 +190,7 @@ def test_registered_component_mapping_preferred_over_generic_fallback_when_avail run_presentation_adapter_assessment(project_id) capability = json.loads((root / MAPPING_PLAN_PATH).read_text(encoding="utf-8"))["capabilities"][0] - assert capability["recommended_legacy_component_type"] == "VocabularyFlipCard" + assert capability["recommended_legacy_component_type"] == "ChoiceQuestion" assert capability["renderer_supported"] is True diff --git a/apps/api/tests/test_presentation_content.py b/apps/api/tests/test_presentation_content.py index 6d7311c..5f03b26 100644 --- a/apps/api/tests/test_presentation_content.py +++ b/apps/api/tests/test_presentation_content.py @@ -22,8 +22,12 @@ LearningActivity, LearningGoal, LearningStatePlan, + LessonProfile, PresentationContentItem, + QualityReport, ) +from hcs_api.pptx_deck import build_pptx_deck_plan +from hcs_api.renderer import render_lesson from hcs_api.pipeline import write_presentation_content_shadow_artifacts from hcs_api.presentation_adapter_assessment import run_presentation_adapter_assessment from hcs_api.presentation_blueprint import compile_shadow_presentation @@ -230,8 +234,31 @@ def test_adapter_uses_content_contract_for_choice_payload() -> None: adapted = adapt_canonical_presentation_blueprint(canonical, plan) - assert adapted.slides[0].components[0].component_type == "VocabularyFlipCard" - assert [item["word"] for item in adapted.slides[0].components[0].data["items"]] == [option.text for option in plan.content_items[0].options] + assert adapted.slides[0].components[0].component_type == "ChoiceQuestion" + assert adapted.slides[0].components[0].data["choices"] == [option.text for option in plan.content_items[0].options] + assert adapted.slides[0].components[0].data["answer"] == "你好" + + +def test_choice_response_renders_as_a_real_choice_component(tmp_path: Path) -> None: + _, _, _, _, canonical, _, plan, _ = _build( + "deterministic_choice", + accepted_values=["你好"], + ) + adapted = adapt_canonical_presentation_blueprint(canonical, plan) + + html = render_lesson( + tmp_path, + LessonProfile(lesson_title="选择练习"), + adapted, + AssetManifest(), + QualityReport(state="pass"), + ).read_text(encoding="utf-8") + deck = build_pptx_deck_plan(adapted) + + assert 'class="component component-container choice-question"' in html + assert 'data-answer="你好"' in html + assert 'class="flip-card"' not in html + assert deck.slides[0].traditional_layout == "choice_question" def test_adapter_uses_content_contract_for_matching_payload() -> None: diff --git a/apps/api/tests/test_presentation_parity.py b/apps/api/tests/test_presentation_parity.py index 213e3ef..8bee878 100644 --- a/apps/api/tests/test_presentation_parity.py +++ b/apps/api/tests/test_presentation_parity.py @@ -17,11 +17,13 @@ LearningActivity, LearningGoal, LearningStatePlan, + LanguageItem, LessonBlueprint, LessonSlide, SlideComponent, ) from hcs_api.presentation_blueprint import compile_shadow_presentation +from hcs_api.presentation_content import attach_content_references, build_presentation_content_plan from hcs_api.presentation_parity import ( ABSTRACT_BINDING_PATH, CANONICAL_BLUEPRINT_PATH, @@ -51,6 +53,7 @@ def _write_inputs(tmp_path: Path, monkeypatch, *, teacher_only: bool = False, pr id="act_1", evidence_ids=["ev_1"], activity_type="teacher_observation" if teacher_only else "scene_choice", + learner_action="" if teacher_only else "Choose the approved response.", learner_facing=not teacher_only, output_type="teacher_notes" if teacher_only else "selection", ) @@ -58,9 +61,22 @@ def _write_inputs(tmp_path: Path, monkeypatch, *, teacher_only: bool = False, pr state, EvidencePlan(evidence_specs=[evidence]), ActivityPlan(activities=[activity]), EvidenceAlignmentReport(), ) assert canonical is not None + content, _ = build_presentation_content_plan( + state, + EvidencePlan(evidence_specs=[evidence]), + ActivityPlan(activities=[activity]), + bindings, + canonical, + [ + LanguageItem(id="lang_nihao", target_form="你好", scaffold_meaning="hello"), + LanguageItem(id="lang_ninhao", target_form="您好", scaffold_meaning="hello (polite)"), + ], + ) + canonical = attach_content_references(canonical, content) storage.write_json(project_id, ABSTRACT_BINDING_PATH, bindings.model_dump(mode="json", by_alias=True)) storage.write_json(project_id, CANONICAL_BLUEPRINT_PATH, canonical.model_dump(mode="json", by_alias=True)) storage.write_json(project_id, SHADOW_REPORT_PATH, shadow.model_dump(mode="json", by_alias=True)) + storage.write_json(project_id, "presentation/presentation_content_plan.json", content.model_dump(mode="json", by_alias=True)) production = LessonBlueprint( lesson_title="你好", slides=[ diff --git a/apps/api/tests/test_presentation_readiness.py b/apps/api/tests/test_presentation_readiness.py index 2953749..847bfc0 100644 --- a/apps/api/tests/test_presentation_readiness.py +++ b/apps/api/tests/test_presentation_readiness.py @@ -172,7 +172,7 @@ def test_presentation_readiness_blocks_zip_export(tmp_path: Path, monkeypatch) - with pytest.raises(PermissionError, match="Presentation readiness gate"): storage.zip_output("blocked_readiness") - with pytest.raises(PermissionError, match="Blueprint artifact is missing"): + with pytest.raises(PermissionError, match="Presentation readiness gate"): storage.zip_output("blocked_readiness", force=True) 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_production_presentation_semantics.py b/apps/api/tests/test_production_presentation_semantics.py new file mode 100644 index 0000000..2b40aeb --- /dev/null +++ b/apps/api/tests/test_production_presentation_semantics.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pptx import Presentation +from fastapi.testclient import TestClient + +import hcs_api.main as main +import hcs_api.pipeline as pipeline +import hcs_api.storage as storage +from hcs_api.blueprint_compatibility import ( + MODE_ADAPTER_MATRIX, + PresentationAdapterError, + adapt_canonical_presentation_blueprint, + build_legacy_blueprint_provenance, + build_legacy_component_mapping, +) +from hcs_api.evidence_alignment import check_evidence_alignment +from hcs_api.state_evidence_kernel import build_full_kernel +from hcs_api.models import ( + ActivityPlan, + AssetManifest, + EvidencePlan, + EvidenceSpec, + LearningActivity, + LearningGoal, + LearningStatePlan, + LessonProfile, + PresentationBindingPlan, + QualityReport, + SourceMaterial, + SourcePage, + TextBlock, + TeachingCandidates, + ProviderSettings, +) +from hcs_api.media import generate_placeholder_media +from hcs_api.pipeline import write_json, write_model +from hcs_api.presentation_blueprint import compile_canonical_presentation +from hcs_api.presentation_content import attach_content_references, build_presentation_content_plan +from hcs_api.presentation_eligibility import evaluate_production_presentation_eligibility +from hcs_api.presentation_teacher import build_presentation_teacher_plan +from hcs_api.pptx_exporter import export_editable_pptx +from hcs_api.pptx_deck import build_pptx_deck_plan +from hcs_api.renderer import render_lesson + + +ROUTE_FIXTURES = { + "greeting_lesson": TeachingCandidates( + route_hint="greeting_lesson", core_vocabulary=[{"word": "你好"}, {"word": "您好"}], + ), + "vocabulary_lesson": TeachingCandidates( + route_hint="vocabulary_lesson", core_vocabulary=[{"word": "苹果"}, {"word": "香蕉"}], + ), + "dialogue_lesson": TeachingCandidates( + route_hint="dialogue_lesson", + core_vocabulary=[{"word": "你好"}, {"word": "再见"}], + dialogue_candidates=[{"speaker": "A", "text": "你好!"}, {"speaker": "B", "text": "再见!"}], + ), + "character_lesson": TeachingCandidates( + route_hint="character_lesson", core_vocabulary=[{"word": "你"}, {"word": "好"}], + character_candidates=["你", "好"], + ), + "grammar_pattern_lesson": TeachingCandidates( + route_hint="grammar_pattern_lesson", core_vocabulary=[{"word": "学习"}], + grammar_candidates=[{"pattern": "我在学习中文呢"}], + ), + "mixed_lesson": TeachingCandidates( + route_hint="mixed_lesson", core_vocabulary=[{"word": "你好"}, {"word": "谢谢"}], + dialogue_candidates=[{"speaker": "A", "text": "你好!"}, {"speaker": "B", "text": "谢谢!"}], + ), +} + +SOURCE_ROUTE_FIXTURES = { + "greeting_lesson": SourceMaterial( + source_type="pdf", + original_filename="greeting.pdf", + pages=[SourcePage( + page_number=1, + title="你好与您好 Greeting", + text_blocks=[TextBlock( + id="greeting-source", + text="问候 打招呼\n你好 nǐ hǎo hello\n你好\n您好 nín hǎo polite hello\n您好\n你 您", + )], + )], + ), + "vocabulary_lesson": SourceMaterial( + source_type="pdf", + original_filename="vocabulary.pdf", + pages=[SourcePage( + page_number=1, + title="水果生词词汇 Vocabulary", + text_blocks=[TextBlock( + id="vocabulary-source", + text="词语 word\n苹果 píng guǒ apple\n苹果\n香蕉 xiāng jiāo banana\n香蕉\n生词 词汇 vocabulary word", + )], + )], + ), + "dialogue_lesson": SourceMaterial( + source_type="pdf", + original_filename="dialogue.pdf", + pages=[SourcePage( + page_number=1, + title="购物对话 Dialogue", + text_blocks=[TextBlock( + id="dialogue-source", + text=( + "A:我要苹果。\nB:我要香蕉。\nA:苹果。\nB:香蕉。\n" + "生词 苹果 píng guǒ apple 苹果 香蕉 xiāng jiāo banana 香蕉" + ), + )], + )], + ), + "character_lesson": SourceMaterial( + source_type="pdf", + original_filename="character.pdf", + pages=[SourcePage( + page_number=1, + title="汉字木 Character", + text_blocks=[TextBlock( + id="character-source", + text="汉字 笔画 笔顺 书写 character\n木 木 木\n人 人\n观察木字的结构。", + )], + )], + ), + "grammar_pattern_lesson": SourceMaterial( + source_type="pdf", + original_filename="grammar.pdf", + pages=[SourcePage( + page_number=1, + title="语法句型 Grammar Pattern", + text_blocks=[TextBlock( + id="grammar-source", + text="语法 句型 grammar pattern\n我在学习中文呢。\n我在学习中文呢。", + )], + )], + ), + "mixed_lesson": SourceMaterial( + source_type="pdf", + original_filename="mixed.pdf", + pages=[SourcePage( + page_number=1, + title="综合:对话、语法与词汇", + text_blocks=[TextBlock( + id="mixed-source", + text=( + "A:我在买苹果呢。\nB:我也买香蕉。\n" + "对话 dialogue 语法 句型 grammar 生词 词汇\n" + "苹果 píng guǒ apple 苹果 香蕉 xiāng jiāo banana 香蕉" + ), + )], + )], + ), +} + + +def _compile_route(route: str): + profile = LessonProfile(lesson_title=f"fixture:{route}") + state, evidence, activities, alignment = build_full_kernel( + profile, ROUTE_FIXTURES[route], [], "beginner", "English", + ) + abstract, canonical, _shadow = compile_canonical_presentation(state, evidence, activities, alignment) + assert canonical is not None + content, content_report = build_presentation_content_plan( + state, evidence, activities, abstract, canonical, [], allow_planned_audio=True, + ) + canonical = attach_content_references(canonical, content) + teacher_plan = build_presentation_teacher_plan(canonical, evidence, activities) + eligibility = evaluate_production_presentation_eligibility( + state, evidence, activities, canonical, teacher_plan, ROUTE_FIXTURES[route], + ) + return state, evidence, activities, alignment, canonical, content, content_report, eligibility + + +@pytest.mark.parametrize("route", tuple(ROUTE_FIXTURES)) +def test_supported_routes_preserve_teaching_semantics(route: str) -> None: + state, evidence, activities, alignment, canonical, content, content_report, eligibility = _compile_route(route) + + assert alignment.state != "blocked" + assert content_report.state != "blocked" + expected_eligibility = "blocked" if route == "character_lesson" else "pass" + assert eligibility.state == expected_eligibility + assert eligibility.route == route + assert eligibility.goals_covered is True + assert eligibility.structural_roles_complete is True + assert { + "lesson_opening", "route_preview", "input_modeling", "learner_activity", "consolidation_summary", + }.issubset({unit.structural_role for unit in canonical.presentation_units}) + + learner_units = [unit for unit in canonical.presentation_units if unit.unit_role == "learner_interaction"] + assert learner_units + assert all(unit.evidence_ids for unit in learner_units) + assert all(unit.trace.activity_id == unit.activity_id for unit in learner_units) + assert all(unit.trace.evidence_ids == unit.evidence_ids for unit in learner_units) + + legacy = None + deck = None + if eligibility.state == "pass": + legacy = adapt_canonical_presentation_blueprint(canonical, content, allow_planned_media=True) + mapping = build_legacy_component_mapping(canonical, legacy, content) + assert mapping.state == "pass" + assert {item.structural_role for item in mapping.mappings}.issuperset({ + "lesson_opening", "route_preview", "input_modeling", "learner_activity", "consolidation_summary", + }) + internal_titles = {"Listening Choice", "Matching Response", "Guided Response", "Role Play Response"} + assert not {slide.title for slide in legacy.slides}.intersection(internal_titles) + deck = build_pptx_deck_plan(legacy) + assert [slide.traditional_layout for slide in deck.slides[:3]] == [ + "cover_title", "objectives_cards", "single_item_focus", + ] + assert deck.slides[-1].traditional_layout == "summary_cards" + assert all(slide.main_focus or slide.target_text for slide in deck.slides) + + if route == "greeting_lesson": + assert any("greeting" in goal.description.lower() for goal in state.learning_goals) + assert {"你好", "您好"}.issubset({item for goal in state.learning_goals for item in goal.target_items}) + elif route == "vocabulary_lesson": + assert any("vocabulary" in goal.description.lower() for goal in state.learning_goals) + assert {"苹果", "香蕉"}.issubset(set(content.content_items[0].display_items)) + elif route == "dialogue_lesson": + assert any(unit.presentation_mode == "role_play_response" for unit in learner_units) + assert any("A:你好" in text or "B:再见" in text for unit in canonical.presentation_units for text in unit.learner_facing_content) + assert deck is not None + assert any(slide.traditional_layout == "dialogue_bubbles" for slide in deck.slides) + elif route == "character_lesson": + assert any(unit.presentation_mode == "character_formation" for unit in learner_units) + assert any("你" in unit.learner_facing_content for unit in learner_units) + assert "PRODUCTION_CHARACTER_FORMATION_CONTRACT_UNAVAILABLE" in eligibility.error_codes + assert "character_formation" in eligibility.unsupported_presentation_modes + assert legacy is None + elif route == "grammar_pattern_lesson": + assert any("我在学习中文呢" in goal.description for goal in state.learning_goals) + assert all(unit.presentation_mode == "guided_response" for unit in learner_units) + assert deck is not None + assert any("我在学习中文呢" in slide.target_text for slide in deck.slides) + else: + assert len(learner_units) >= 2 + assert len({unit.presentation_mode for unit in learner_units}) >= 2 + + +def test_eligibility_blocks_missing_route_semantics() -> None: + state, evidence, activities, _alignment, canonical, _content, _report, _eligibility = _compile_route("vocabulary_lesson") + teacher_plan = build_presentation_teacher_plan(canonical, evidence, activities) + unsupported_state = state.model_copy(update={"route_hint": "unsupported_lesson"}) + report = evaluate_production_presentation_eligibility( + unsupported_state, evidence, activities, canonical, teacher_plan, + ) + + assert report.state == "blocked" + assert "PRODUCTION_ROUTE_UNSUPPORTED" in report.error_codes + assert not report.route_supported + + +def test_eligibility_blocks_placeholder_route_content() -> None: + state, evidence, activities, _alignment, canonical, _content, _report, _eligibility = _compile_route("grammar_pattern_lesson") + teacher_plan = build_presentation_teacher_plan(canonical, evidence, activities) + report = evaluate_production_presentation_eligibility( + state, evidence, activities, canonical, teacher_plan, + TeachingCandidates(route_hint="grammar_pattern_lesson", core_vocabulary=[{"word": "学习"}]), + ) + + assert report.state == "blocked" + assert "PRODUCTION_GOAL_COVERAGE_INCOMPLETE" in report.error_codes + assert any("placeholder content" in finding for finding in report.blocking) + + +def test_adapter_matrix_is_explicit_and_unknown_mode_blocks() -> None: + assert MODE_ADAPTER_MATRIX["listening_choice"]["component_type"] == "ListenAndChoose" + assert MODE_ADAPTER_MATRIX["matching_response"]["component_type"] == "MatchGame" + assert MODE_ADAPTER_MATRIX["choice_response"]["component_type"] == "ChoiceQuestion" + assert MODE_ADAPTER_MATRIX["guided_response"]["component_type"] is None + assert MODE_ADAPTER_MATRIX["role_play_response"]["slide_type"] == "DialogueSlide" + assert MODE_ADAPTER_MATRIX["character_formation"]["component_type"] == "CharacterFormation" + + _state, _evidence, _activities, _alignment, canonical, content, _report, _eligibility = _compile_route("grammar_pattern_lesson") + unit = next(unit for unit in canonical.presentation_units if unit.unit_role == "learner_interaction") + unit.presentation_mode = "unsupported_mode" + with pytest.raises(PresentationAdapterError, match="Unsupported canonical presentation mode"): + adapt_canonical_presentation_blueprint(canonical) + with pytest.raises(PresentationAdapterError, match="Unsupported canonical presentation mode"): + adapt_canonical_presentation_blueprint(canonical, content) + + +def _teacher_contract(): + goal = LearningGoal( + id="goal_teacher_vocab", description="Observe vocabulary use.", skill_focus="communicative", + target_language=["你好"], expected_behavior="Learner uses 你好.", required_state_to_reach="controlled", + ) + state = LearningStatePlan( + lesson_title="教师通道", route_hint="vocabulary_lesson", learner_level="beginner", learning_goals=[goal], + ) + evidence = EvidenceSpec( + id="ev_teacher_vocab", goal_id=goal.id, evidence_type="teacher_observation", + collection_method="teacher_observation", observable_behavior="Learner uses 你好.", + teacher_observation_notes="记录学生是否主动使用你好。", target_items=["你好"], + failure_action={"remediation_type": "rescaffold"}, + ) + activity = LearningActivity( + id="act_teacher_vocab", evidence_ids=[evidence.id], activity_type="teacher_observation", + teacher_action="观察并记录。", learner_facing=False, output_type="teacher_notes", + fallback_activity="再次示范你好。", + ) + evidence_plan = EvidencePlan(evidence_specs=[evidence]) + activity_plan = ActivityPlan(activities=[activity]) + alignment = check_evidence_alignment(state, evidence_plan, activity_plan, "beginner") + abstract, canonical, _shadow = compile_canonical_presentation(state, evidence_plan, activity_plan, alignment) + assert canonical is not None + content, _report = build_presentation_content_plan( + state, evidence_plan, activity_plan, abstract, canonical, [], allow_planned_audio=True, + ) + canonical = attach_content_references(canonical, content) + legacy = adapt_canonical_presentation_blueprint(canonical, content) + mapping = build_legacy_component_mapping(canonical, legacy, content) + teacher_plan = build_presentation_teacher_plan(canonical, evidence_plan, activity_plan, mapping) + return state, evidence_plan, activity_plan, alignment, canonical, content, legacy, mapping, teacher_plan + + +def test_teacher_channel_reaches_pptx_notes_and_not_classroom_html(tmp_path: Path, monkeypatch) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + project_id = "teacher-channel-contract" + root = storage.ensure_project(project_id) + state, evidence, activities, alignment, canonical, content, legacy, mapping, teacher_plan = _teacher_contract() + + write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title="教师通道")) + write_model(project_id, "lesson_blueprint.json", legacy) + write_model(project_id, "asset_manifest.json", AssetManifest()) + write_model(project_id, "quality_report.json", QualityReport(state="pass")) + for path, model in ( + ("learning/learning_state_plan.json", state), + ("learning/evidence_plan.json", evidence), + ("learning/activity_plan.json", activities), + ("quality/evidence_alignment_report.json", alignment), + ("presentation/presentation_blueprint.json", canonical), + ("presentation/presentation_content_plan.json", content), + ("presentation/presentation_content_plan.reconciled.json", content), + ("presentation/presentation_teacher_plan.json", teacher_plan), + ("presentation/legacy_component_mapping.json", mapping), + ("presentation/activity_bindings.json", PresentationBindingPlan()), + ): + write_json(project_id, path, model.model_dump(mode="json", by_alias=True)) + write_json(project_id, "quality/presentation_readiness_report.json", {"state": "pass"}) + write_json(project_id, "presentation/binding_quality_report.json", {"state": "pass"}) + write_json(project_id, "quality/presentation_asset_reconciliation_report.json", {"state": "pass"}) + write_json(project_id, "quality/evidence_alignment_report.json", alignment.model_dump(mode="json", by_alias=True)) + provenance = build_legacy_blueprint_provenance( + canonical, legacy, mapping, + reconciled_content_fingerprint=storage.artifact_fingerprint( + project_id, "presentation/presentation_content_plan.reconciled.json", + ) or "", + ) + write_json(project_id, "presentation/legacy_blueprint_provenance.json", provenance.model_dump(mode="json", by_alias=True)) + + pptx_path = export_editable_pptx(project_id) + notes = "\n".join( + slide.notes_slide.notes_text_frame.text + for slide in Presentation(pptx_path).slides + ) + assert "记录学生是否主动使用你好" in notes + assert teacher_plan.items[0].target_legacy_slide_id is not None + assert teacher_plan.items[0].target_legacy_slide_id > 0 + + html_path = render_lesson(root, LessonProfile(lesson_title="教师通道"), legacy, AssetManifest(), QualityReport(), render_mode="classroom") + html = html_path.read_text(encoding="utf-8") + assert "记录学生是否主动使用你好" not in html + payload = json.loads(html.split('id="lesson-data">', 1)[1].split("", 1)[0]) + assert all(not slide.get("teacher_only") for slide in payload["blueprint"]["slides"]) + assert all(slide_id != 0 for slide_id in [item.get("target_legacy_slide_id") for item in teacher_plan.model_dump()["items"]]) + + +def _seed_api_project(project_id: str) -> None: + storage.ensure_project(project_id) + storage.write_model( + project_id, "source_material.json", + SourceMaterial( + source_type="pdf", + original_filename=f"{project_id}.pdf", + pages=[SourcePage( + page_number=1, + title="生词 词语 词汇 词卡", + text_blocks=[TextBlock(id="fixture", text="你好 你好 nǐ hǎo hello 谢谢 谢谢 xièxie thanks")], + )], + ), + ) + storage.write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title="一致性")) + + +def test_stepwise_and_full_pipeline_share_the_same_authoritative_contract(tmp_path: Path, monkeypatch) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime / "config" / "provider_settings.json") + _seed_api_project("stepwise") + _seed_api_project("full") + client = TestClient(main.app) + + def placeholder_media(root, blueprint, *_args, **_kwargs): + return generate_placeholder_media(root, blueprint) + + monkeypatch.setattr(main, "generate_project_media", placeholder_media) + assert client.post("/api/projects/stepwise/blueprint").status_code == 200 + assert client.post("/api/projects/stepwise/media").status_code == 200 + assert client.post("/api/projects/stepwise/render").status_code == 200 + + monkeypatch.setattr(pipeline, "generate_project_media", placeholder_media) + full_state = pipeline.run_full_pipeline("full", storage.project_dir("full"), ProviderSettings()) + assert full_state.status == "rendered" + + for path in ( + "presentation/presentation_blueprint.json", + "presentation/presentation_content_plan.reconciled.json", + "presentation/presentation_media_request_plan.json", + "blueprints/lesson_blueprint.json", + "presentation/legacy_component_mapping.json", + "presentation/presentation_teacher_plan.json", + ): + assert storage.read_json("stepwise", path) == storage.read_json("full", path), path + + +def test_reconciliation_block_stops_stepwise_and_full_pipeline(tmp_path: Path, monkeypatch) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime / "config" / "provider_settings.json") + _seed_api_project("stepwise-blocked") + _seed_api_project("full-blocked") + client = TestClient(main.app) + + monkeypatch.setattr(main, "generate_project_media", lambda *_args, **_kwargs: AssetManifest()) + assert client.post("/api/projects/stepwise-blocked/blueprint").status_code == 200 + media_response = client.post("/api/projects/stepwise-blocked/media") + assert media_response.status_code == 409 + assert media_response.json()["detail"]["code"] == "presentation_media_contract_blocked" + step_root = storage.project_dir("stepwise-blocked") + assert not (step_root / "blueprints/lesson_blueprint.json").exists() + assert not (step_root / "courseware/lesson.html").exists() + assert storage.read_json("stepwise-blocked", "quality/presentation_revision_plan.json")["state"] == "blocked" + + monkeypatch.setattr(pipeline, "generate_project_media", lambda *_args, **_kwargs: AssetManifest()) + pipeline.run_full_pipeline("full-blocked", storage.project_dir("full-blocked"), ProviderSettings()) + full_root = storage.project_dir("full-blocked") + assert not (full_root / "blueprints/lesson_blueprint.json").exists() + assert not (full_root / "courseware/lesson.html").exists() + assert storage.read_json("full-blocked", "quality/presentation_revision_plan.json")["state"] == "blocked" + + +@pytest.mark.parametrize("route", tuple(SOURCE_ROUTE_FIXTURES)) +def test_real_source_reaches_only_semantically_eligible_production( + route: str, + tmp_path: Path, + monkeypatch, +) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + project_id = f"source-{route}" + root = storage.ensure_project(project_id) + storage.write_model(project_id, "source_material.json", SOURCE_ROUTE_FIXTURES[route]) + storage.write_model( + project_id, + "lesson_profile.json", + LessonProfile(lesson_title=f"source fixture: {route}"), + ) + + stage = pipeline._compile_blueprint_stage(project_id, ProviderSettings()) + + assert stage.candidates.route_hint == route + assert stage.state_plan.route_hint == route + assert stage.alignment.state != "blocked" + assert stage.canonical is not None + learner_units = [ + unit + for unit in stage.canonical.presentation_units + if unit.unit_role == "learner_interaction" + ] + assert learner_units + assert all(unit.evidence_ids and unit.activity_id for unit in learner_units) + assert { + "lesson_opening", + "route_preview", + "input_modeling", + "learner_activity", + "consolidation_summary", + }.issubset({unit.structural_role for unit in stage.canonical.presentation_units}) + + if route == "character_lesson": + assert stage.blocked is True + assert stage.eligibility is not None + assert "PRODUCTION_CHARACTER_FORMATION_CONTRACT_UNAVAILABLE" in stage.eligibility.error_codes + assert stage.legacy is None + assert not (root / "blueprints/lesson_blueprint.json").exists() + return + + assert stage.blocked is False + assert stage.eligibility is not None and stage.eligibility.state == "pass" + assert stage.legacy is not None + assert (root / "blueprints/lesson_blueprint.json").is_file() + component_types = { + component.component_type + for slide in stage.legacy.slides + for component in slide.components + } + if route == "greeting_lesson": + assert {"你好", "您好"}.issubset({ + item + for goal in stage.state_plan.learning_goals + for item in goal.target_items + }) + assert "ChoiceQuestion" in component_types + elif route == "vocabulary_lesson": + targets = { + item + for goal in stage.state_plan.learning_goals + for item in goal.target_items + } + assert {"苹果", "香蕉"}.issubset(targets) + assert "ChoiceQuestion" in component_types + elif route == "dialogue_lesson": + assert any(unit.presentation_mode == "role_play_response" for unit in learner_units) + assert any( + "苹果" in text or "香蕉" in text + for unit in stage.canonical.presentation_units + for text in unit.learner_facing_content + ) + elif route == "grammar_pattern_lesson": + assert any( + "sb. + 在 + V + 呢" in item + for goal in stage.state_plan.learning_goals + for item in goal.target_items + ) + assert all(unit.presentation_mode == "guided_response" for unit in learner_units) + elif route == "mixed_lesson": + assert len(learner_units) >= 2 + assert len({unit.presentation_mode for unit in learner_units}) >= 2 + + +def test_dialogue_goal_evidence_transition_contract_is_consistent() -> None: + state, evidence, _activities, alignment, canonical, _content, _report, eligibility = _compile_route( + "dialogue_lesson", + ) + + assert alignment.state != "blocked" + assert eligibility.state == "pass" + goal_by_id = {goal.goal_id: goal for goal in state.learning_goals} + for spec in evidence.evidence_specs: + transitions = [ + transition + for transition in state.transitions + if spec.evidence_id in transition.required_evidence_ids + ] + assert len(transitions) == 1 + transition = transitions[0] + assert spec.state_from == transition.from_state + assert spec.state_to == transition.to_state + assert spec.state_to == goal_by_id[spec.goal_id].required_state_to_reach + + understanding = next( + transition for transition in state.transitions if transition.to_state == "understood" + ) + assert { + "ev_dialogue_input", + "ev_dialogue_response", + }.issubset(set(understanding.required_evidence_ids)) + assert any( + unit.presentation_mode == "role_play_response" + and unit.evidence_ids == ["ev_role_play_dialogue"] + for unit in canonical.presentation_units + ) 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..235a1cc --- /dev/null +++ b/apps/api/tests/test_state_evidence_production_cutover.py @@ -0,0 +1,336 @@ +"""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, + LanguageItem, + LessonProfile, + ProviderSettings, + QualityReport, + SourceMaterial, + SourcePage, + TextBlock, +) +from hcs_api.presentation_blueprint import compile_canonical_presentation +from hcs_api.presentation_content import attach_content_references, build_presentation_content_plan +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", + pages=[SourcePage( + page_number=1, + title="混合课:词汇与对话", + text_blocks=[TextBlock(id="fixture", text="词汇 你好 谢谢\nA:你好!\nB:谢谢!")], + )], + ), + ) + 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", + "quality/production_presentation_eligibility_report.json", + "presentation/presentation_teacher_plan.json", + "presentation/legacy_component_mapping.json", + "presentation/legacy_blueprint_provenance.json", + "quality/presentation_asset_reconciliation_report.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_full_pipeline_honors_optional_release_quality_blockers(tmp_path, monkeypatch) -> None: + _configure_runtime(tmp_path, monkeypatch) + project_id = "blocked-optional-full-pipeline" + root = _seed_project(project_id) + render_and_check = pipeline.render_and_check + + def render_with_blocked_review(*args, **kwargs): + report = render_and_check(*args, **kwargs) + storage.write_json( + project_id, + "quality/courseware_review_report.json", + {"state": "blocked", "blocking": ["teacher-channel review failed"]}, + ) + return report + + monkeypatch.setattr(pipeline, "render_and_check", render_with_blocked_review) + + state = pipeline.run_full_pipeline(project_id, root, ProviderSettings()) + + assert state.gate_summary.overall_state == "blocked" + assert "teacher-channel review failed" in " ".join(state.gate_summary.blocking_reasons) + assert storage.latest_export_path(project_id) is None + + +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) + bindings, canonical, _ = compile_canonical_presentation(state, evidence_plan, activity_plan, alignment) + assert canonical is not None + content, content_report = build_presentation_content_plan( + state, + evidence_plan, + activity_plan, + bindings, + canonical, + [ + LanguageItem(id="lang_nihao", target_form="你好", scaffold_meaning="hello"), + LanguageItem(id="lang_ninhao", target_form="您好", scaffold_meaning="hello (polite)"), + ], + ) + assert content_report.state != "blocked" + canonical = attach_content_references(canonical, content) + + production_legacy = adapt_canonical_presentation_blueprint(canonical, content) + repeated_legacy = adapt_canonical_presentation_blueprint(canonical, content) + mapping = build_legacy_component_mapping(canonical, production_legacy, content) + 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, + content, + 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_blueprint_api_blocks_ineligible_source_without_shrinkage_or_fallback(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 = "ineligible-blueprint-api" + storage.ensure_project(project_id) + storage.write_model( + project_id, + "source_material.json", + SourceMaterial( + source_type="pdf", + original_filename="unreadable.pdf", + pages=[SourcePage( + page_number=1, + title="普通资料", + text_blocks=[TextBlock(id="fixture", text="OCR noise 123 !!!")], + )], + ), + ) + storage.write_model(project_id, "lesson_profile.json", LessonProfile(lesson_title="普通资料")) + storage.set_profile_state(project_id, "confirmed") + + response = TestClient(app).post(f"/api/projects/{project_id}/blueprint") + + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["code"] == "production_presentation_eligibility_blocked" + assert "PRODUCTION_GOAL_COVERAGE_INCOMPLETE" in detail["error_codes"] + root = storage.project_dir(project_id) + assert not (root / "blueprints/lesson_blueprint.json").exists() + assert storage.read_json(project_id, "quality/production_presentation_eligibility_report.json")["state"] == "blocked" + + +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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 678ccea..355ef8a 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() {