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 辅助语言教学课件生成器 — 面向国际中文教育**
-[](#)
+[](#)
[](#)
[](#)
@@ -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(
{escape(_hint)}
+ {empty}{t("presentation.compatibility")}
{blueprint ? ( -