Spaces:
Sleeping
Sleeping
Implement P0 slide workflow foundation
Browse files- .gitignore +5 -0
- README.md +12 -1
- app.py +435 -0
- course_slide_factory/__init__.py +2 -0
- course_slide_factory/compat.py +22 -0
- course_slide_factory/constants.py +150 -0
- course_slide_factory/fixtures.py +210 -0
- course_slide_factory/models.py +351 -0
- course_slide_factory/quality.py +1451 -0
- course_slide_factory/workflow.py +617 -0
- pyproject.toml +12 -0
- requirements.txt +4 -0
- tests/test_p0_quality.py +253 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.venv/
|
README.md
CHANGED
|
@@ -12,4 +12,15 @@ license: mit
|
|
| 12 |
short_description: 'Slide creation AI assistant '
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
short_description: 'Slide creation AI assistant '
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Course Slide Factory
|
| 16 |
+
|
| 17 |
+
Gradio workflow console for generating, reviewing, approving, and export-checking
|
| 18 |
+
course slide decks with deterministic P0 trust and readiness gates.
|
| 19 |
+
|
| 20 |
+
## Local checks
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
python -m pytest
|
| 24 |
+
python -m ruff check .
|
| 25 |
+
python app.py
|
| 26 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from course_slide_factory.constants import STAGE_IDS, STAGE_LABELS
|
| 9 |
+
from course_slide_factory.workflow import (
|
| 10 |
+
audit_table,
|
| 11 |
+
build_empty_state,
|
| 12 |
+
current_artifact_diff,
|
| 13 |
+
current_artifact_text,
|
| 14 |
+
deck_health_table,
|
| 15 |
+
final_render_export,
|
| 16 |
+
generate_stage,
|
| 17 |
+
grade_stage_action,
|
| 18 |
+
improve_with_ai,
|
| 19 |
+
issue_table,
|
| 20 |
+
objective_matrix_table,
|
| 21 |
+
preflight_table,
|
| 22 |
+
run_preflight_action,
|
| 23 |
+
save_human_edits,
|
| 24 |
+
slide_inventory_table,
|
| 25 |
+
stage_status_table,
|
| 26 |
+
state_from_dict,
|
| 27 |
+
state_to_dict,
|
| 28 |
+
update_setup_from_inputs,
|
| 29 |
+
approve_and_continue,
|
| 30 |
+
)
|
| 31 |
+
from course_slide_factory.quality import get_stage_lock_reasons
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
CSS = """
|
| 35 |
+
.gradio-container { max-width: 1480px !important; }
|
| 36 |
+
textarea, .cm-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
| 37 |
+
.compact-panel table { font-size: 12px; }
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _load_or_update_state(
|
| 42 |
+
state_data: dict[str, Any] | None,
|
| 43 |
+
deck_title: str,
|
| 44 |
+
source_url: str,
|
| 45 |
+
template_url: str,
|
| 46 |
+
output_folder_id: str,
|
| 47 |
+
dry_run: bool,
|
| 48 |
+
objectives_text: str,
|
| 49 |
+
source_text: str,
|
| 50 |
+
mutation_target_url: str,
|
| 51 |
+
production_export_requested: bool,
|
| 52 |
+
):
|
| 53 |
+
state = state_from_dict(state_data)
|
| 54 |
+
if not state_data:
|
| 55 |
+
state = build_empty_state(
|
| 56 |
+
deck_title=deck_title,
|
| 57 |
+
source_url=source_url,
|
| 58 |
+
template_url=template_url,
|
| 59 |
+
output_folder_id=output_folder_id,
|
| 60 |
+
dry_run=dry_run,
|
| 61 |
+
mutation_target_url=mutation_target_url,
|
| 62 |
+
production_export_requested=production_export_requested,
|
| 63 |
+
)
|
| 64 |
+
state = update_setup_from_inputs(
|
| 65 |
+
state,
|
| 66 |
+
deck_title=deck_title,
|
| 67 |
+
source_url=source_url,
|
| 68 |
+
template_url=template_url,
|
| 69 |
+
output_folder_id=output_folder_id,
|
| 70 |
+
dry_run=dry_run,
|
| 71 |
+
objectives_text=objectives_text,
|
| 72 |
+
source_text=source_text,
|
| 73 |
+
mutation_target_url=mutation_target_url or None,
|
| 74 |
+
production_export_requested=production_export_requested,
|
| 75 |
+
)
|
| 76 |
+
return state
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _render_outputs(state, stage_id: str, message: str):
|
| 80 |
+
lock_reasons = "\n".join(get_stage_lock_reasons(stage_id, state)) or "Unlocked"
|
| 81 |
+
state_json = json.dumps(state_to_dict(state), indent=2, sort_keys=True)
|
| 82 |
+
return (
|
| 83 |
+
state_to_dict(state),
|
| 84 |
+
message,
|
| 85 |
+
current_artifact_text(state, stage_id),
|
| 86 |
+
current_artifact_diff(state, stage_id),
|
| 87 |
+
lock_reasons,
|
| 88 |
+
deck_health_table(state),
|
| 89 |
+
slide_inventory_table(state),
|
| 90 |
+
objective_matrix_table(state),
|
| 91 |
+
preflight_table(state),
|
| 92 |
+
stage_status_table(state),
|
| 93 |
+
issue_table(state),
|
| 94 |
+
audit_table(state),
|
| 95 |
+
state_json,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def handle_update_setup(
|
| 100 |
+
state_data,
|
| 101 |
+
deck_title,
|
| 102 |
+
source_url,
|
| 103 |
+
template_url,
|
| 104 |
+
output_folder_id,
|
| 105 |
+
dry_run,
|
| 106 |
+
objectives_text,
|
| 107 |
+
source_text,
|
| 108 |
+
mutation_target_url,
|
| 109 |
+
production_export_requested,
|
| 110 |
+
stage_id,
|
| 111 |
+
):
|
| 112 |
+
state = _load_or_update_state(
|
| 113 |
+
state_data,
|
| 114 |
+
deck_title,
|
| 115 |
+
source_url,
|
| 116 |
+
template_url,
|
| 117 |
+
output_folder_id,
|
| 118 |
+
dry_run,
|
| 119 |
+
objectives_text,
|
| 120 |
+
source_text,
|
| 121 |
+
mutation_target_url,
|
| 122 |
+
production_export_requested,
|
| 123 |
+
)
|
| 124 |
+
return _render_outputs(state, stage_id, "Setup saved in working state.")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def handle_generate(
|
| 128 |
+
state_data,
|
| 129 |
+
deck_title,
|
| 130 |
+
source_url,
|
| 131 |
+
template_url,
|
| 132 |
+
output_folder_id,
|
| 133 |
+
dry_run,
|
| 134 |
+
objectives_text,
|
| 135 |
+
source_text,
|
| 136 |
+
mutation_target_url,
|
| 137 |
+
production_export_requested,
|
| 138 |
+
stage_id,
|
| 139 |
+
):
|
| 140 |
+
state = _load_or_update_state(
|
| 141 |
+
state_data,
|
| 142 |
+
deck_title,
|
| 143 |
+
source_url,
|
| 144 |
+
template_url,
|
| 145 |
+
output_folder_id,
|
| 146 |
+
dry_run,
|
| 147 |
+
objectives_text,
|
| 148 |
+
source_text,
|
| 149 |
+
mutation_target_url,
|
| 150 |
+
production_export_requested,
|
| 151 |
+
)
|
| 152 |
+
state, message = generate_stage(state, stage_id)
|
| 153 |
+
return _render_outputs(state, stage_id, message)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def handle_grade(state_data, stage_id):
|
| 157 |
+
state = state_from_dict(state_data)
|
| 158 |
+
state, message = grade_stage_action(state, stage_id)
|
| 159 |
+
return _render_outputs(state, stage_id, message)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def handle_save_edits(
|
| 163 |
+
state_data,
|
| 164 |
+
stage_id,
|
| 165 |
+
edited_content,
|
| 166 |
+
reviewer_name,
|
| 167 |
+
reviewer_summary,
|
| 168 |
+
requested_changes_json,
|
| 169 |
+
):
|
| 170 |
+
state = state_from_dict(state_data)
|
| 171 |
+
try:
|
| 172 |
+
state, message = save_human_edits(
|
| 173 |
+
state,
|
| 174 |
+
stage_id,
|
| 175 |
+
edited_content,
|
| 176 |
+
reviewer_name,
|
| 177 |
+
reviewer_summary,
|
| 178 |
+
requested_changes_json or "[]",
|
| 179 |
+
)
|
| 180 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 181 |
+
message = f"Reviewer notes were not saved: {exc}"
|
| 182 |
+
return _render_outputs(state, stage_id, message)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def handle_improve(state_data, stage_id):
|
| 186 |
+
state = state_from_dict(state_data)
|
| 187 |
+
state, message = improve_with_ai(state, stage_id)
|
| 188 |
+
return _render_outputs(state, stage_id, message)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def handle_approve(state_data, stage_id, reviewer_name):
|
| 192 |
+
state = state_from_dict(state_data)
|
| 193 |
+
state, message = approve_and_continue(state, stage_id, reviewer_name)
|
| 194 |
+
return _render_outputs(state, stage_id, message)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def handle_preflight(state_data, stage_id):
|
| 198 |
+
state = state_from_dict(state_data)
|
| 199 |
+
state, message = run_preflight_action(state)
|
| 200 |
+
return _render_outputs(state, stage_id, message)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def handle_export(state_data, stage_id):
|
| 204 |
+
state = state_from_dict(state_data)
|
| 205 |
+
state, message = final_render_export(state)
|
| 206 |
+
return _render_outputs(state, stage_id, message)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def handle_stage_change(state_data, stage_id):
|
| 210 |
+
state = state_from_dict(state_data)
|
| 211 |
+
return _render_outputs(state, stage_id, f"Selected {STAGE_LABELS[stage_id]}.")
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def build_app() -> gr.Blocks:
|
| 215 |
+
stage_choices = [(f"{index + 1}. {STAGE_LABELS[stage_id]}", stage_id) for index, stage_id in enumerate(STAGE_IDS)]
|
| 216 |
+
with gr.Blocks(title="Course Slide Factory") as demo:
|
| 217 |
+
state_store = gr.State()
|
| 218 |
+
|
| 219 |
+
gr.Markdown("## Course Slide Factory")
|
| 220 |
+
with gr.Row():
|
| 221 |
+
with gr.Column(scale=1, min_width=320):
|
| 222 |
+
deck_title = gr.Textbox(label="Deck title", value="Course Slide Deck")
|
| 223 |
+
source_url = gr.Textbox(label="Source URL", value="mock://source/course")
|
| 224 |
+
template_url = gr.Textbox(label="Template URL", value="mock://template/course")
|
| 225 |
+
output_folder_id = gr.Textbox(label="Output folder ID", value="")
|
| 226 |
+
mutation_target_url = gr.Textbox(label="Mutation target URL", value="")
|
| 227 |
+
with gr.Row():
|
| 228 |
+
dry_run = gr.Checkbox(label="Dry run", value=True)
|
| 229 |
+
production_export_requested = gr.Checkbox(label="Production export", value=False)
|
| 230 |
+
objectives_text = gr.Textbox(
|
| 231 |
+
label="Learning objectives",
|
| 232 |
+
lines=5,
|
| 233 |
+
value=(
|
| 234 |
+
"obj_1: Explain the core course concept.\n"
|
| 235 |
+
"obj_2: Apply the concept to a worked example."
|
| 236 |
+
),
|
| 237 |
+
)
|
| 238 |
+
source_text = gr.Textbox(
|
| 239 |
+
label="Source text",
|
| 240 |
+
lines=5,
|
| 241 |
+
value="Mock source text for deterministic dry-run generation.",
|
| 242 |
+
)
|
| 243 |
+
update_setup = gr.Button("Save Setup", variant="secondary")
|
| 244 |
+
|
| 245 |
+
with gr.Column(scale=2):
|
| 246 |
+
stage_id = gr.Dropdown(
|
| 247 |
+
label="Workflow stage",
|
| 248 |
+
choices=stage_choices,
|
| 249 |
+
value=STAGE_IDS[0],
|
| 250 |
+
interactive=True,
|
| 251 |
+
)
|
| 252 |
+
with gr.Row():
|
| 253 |
+
generate = gr.Button("Generate", variant="primary")
|
| 254 |
+
grade = gr.Button("Grade")
|
| 255 |
+
improve = gr.Button("Improve with AI")
|
| 256 |
+
approve = gr.Button("Approve & Continue", variant="primary")
|
| 257 |
+
with gr.Row():
|
| 258 |
+
preflight = gr.Button("Run Preflight")
|
| 259 |
+
export = gr.Button("Final Render & Export", variant="stop")
|
| 260 |
+
status = gr.Markdown()
|
| 261 |
+
lock_reasons = gr.Textbox(label="Lock reasons", lines=4, interactive=False)
|
| 262 |
+
|
| 263 |
+
with gr.Tabs():
|
| 264 |
+
with gr.Tab("Artifact"):
|
| 265 |
+
artifact_editor = gr.Textbox(
|
| 266 |
+
label="Current artifact / human edit",
|
| 267 |
+
lines=16,
|
| 268 |
+
interactive=True,
|
| 269 |
+
)
|
| 270 |
+
with gr.Row():
|
| 271 |
+
reviewer_name = gr.Textbox(label="Reviewer", value="human_reviewer")
|
| 272 |
+
reviewer_summary = gr.Textbox(label="Reviewer summary")
|
| 273 |
+
requested_changes_json = gr.Textbox(
|
| 274 |
+
label="Requested changes JSON",
|
| 275 |
+
lines=6,
|
| 276 |
+
value="[]",
|
| 277 |
+
)
|
| 278 |
+
save_edits = gr.Button("Save Human Edits")
|
| 279 |
+
artifact_diff = gr.Textbox(label="Artifact diff", lines=12, interactive=False)
|
| 280 |
+
with gr.Tab("Deck Health"):
|
| 281 |
+
deck_health = gr.Dataframe(
|
| 282 |
+
headers=["Metric", "Value"],
|
| 283 |
+
interactive=False,
|
| 284 |
+
elem_classes=["compact-panel"],
|
| 285 |
+
)
|
| 286 |
+
with gr.Tab("Slide Inventory"):
|
| 287 |
+
slide_inventory = gr.Dataframe(
|
| 288 |
+
headers=[
|
| 289 |
+
"Slide #",
|
| 290 |
+
"Title",
|
| 291 |
+
"Role",
|
| 292 |
+
"Objectives",
|
| 293 |
+
"Claims",
|
| 294 |
+
"Visuals",
|
| 295 |
+
"Aggregate",
|
| 296 |
+
"Technical",
|
| 297 |
+
"Pedagogical",
|
| 298 |
+
"Aesthetic",
|
| 299 |
+
"Status",
|
| 300 |
+
"Issues",
|
| 301 |
+
"Stale?",
|
| 302 |
+
],
|
| 303 |
+
interactive=False,
|
| 304 |
+
elem_classes=["compact-panel"],
|
| 305 |
+
)
|
| 306 |
+
with gr.Tab("Objectives"):
|
| 307 |
+
objective_matrix = gr.Dataframe(
|
| 308 |
+
headers=[
|
| 309 |
+
"Objective ID",
|
| 310 |
+
"Objective",
|
| 311 |
+
"Mapped Slides",
|
| 312 |
+
"Coverage Score",
|
| 313 |
+
"Coverage Status",
|
| 314 |
+
"Evidence Count",
|
| 315 |
+
"Issues",
|
| 316 |
+
],
|
| 317 |
+
interactive=False,
|
| 318 |
+
elem_classes=["compact-panel"],
|
| 319 |
+
)
|
| 320 |
+
with gr.Tab("Preflight"):
|
| 321 |
+
preflight_results = gr.Dataframe(
|
| 322 |
+
headers=["Field", "Value"],
|
| 323 |
+
interactive=False,
|
| 324 |
+
elem_classes=["compact-panel"],
|
| 325 |
+
)
|
| 326 |
+
with gr.Tab("Stages"):
|
| 327 |
+
stage_status = gr.Dataframe(
|
| 328 |
+
headers=[
|
| 329 |
+
"#",
|
| 330 |
+
"Stage",
|
| 331 |
+
"Score",
|
| 332 |
+
"Artifact",
|
| 333 |
+
"Artifact Status",
|
| 334 |
+
"Stale?",
|
| 335 |
+
"Can Unlock Next?",
|
| 336 |
+
"Lock Reasons",
|
| 337 |
+
],
|
| 338 |
+
interactive=False,
|
| 339 |
+
elem_classes=["compact-panel"],
|
| 340 |
+
)
|
| 341 |
+
with gr.Tab("Issues"):
|
| 342 |
+
issues = gr.Dataframe(
|
| 343 |
+
headers=[
|
| 344 |
+
"Issue ID",
|
| 345 |
+
"Type",
|
| 346 |
+
"Severity",
|
| 347 |
+
"Stage",
|
| 348 |
+
"Slide",
|
| 349 |
+
"Message",
|
| 350 |
+
"Resolved?",
|
| 351 |
+
],
|
| 352 |
+
interactive=False,
|
| 353 |
+
elem_classes=["compact-panel"],
|
| 354 |
+
)
|
| 355 |
+
with gr.Tab("Audit"):
|
| 356 |
+
audit = gr.Dataframe(
|
| 357 |
+
headers=[
|
| 358 |
+
"Event ID",
|
| 359 |
+
"Type",
|
| 360 |
+
"Timestamp",
|
| 361 |
+
"Stage",
|
| 362 |
+
"Slide",
|
| 363 |
+
"Issue",
|
| 364 |
+
"Reason",
|
| 365 |
+
],
|
| 366 |
+
interactive=False,
|
| 367 |
+
elem_classes=["compact-panel"],
|
| 368 |
+
)
|
| 369 |
+
with gr.Tab("State JSON"):
|
| 370 |
+
state_json = gr.Textbox(label="State", lines=18, interactive=False)
|
| 371 |
+
|
| 372 |
+
outputs = [
|
| 373 |
+
state_store,
|
| 374 |
+
status,
|
| 375 |
+
artifact_editor,
|
| 376 |
+
artifact_diff,
|
| 377 |
+
lock_reasons,
|
| 378 |
+
deck_health,
|
| 379 |
+
slide_inventory,
|
| 380 |
+
objective_matrix,
|
| 381 |
+
preflight_results,
|
| 382 |
+
stage_status,
|
| 383 |
+
issues,
|
| 384 |
+
audit,
|
| 385 |
+
state_json,
|
| 386 |
+
]
|
| 387 |
+
setup_inputs = [
|
| 388 |
+
state_store,
|
| 389 |
+
deck_title,
|
| 390 |
+
source_url,
|
| 391 |
+
template_url,
|
| 392 |
+
output_folder_id,
|
| 393 |
+
dry_run,
|
| 394 |
+
objectives_text,
|
| 395 |
+
source_text,
|
| 396 |
+
mutation_target_url,
|
| 397 |
+
production_export_requested,
|
| 398 |
+
stage_id,
|
| 399 |
+
]
|
| 400 |
+
|
| 401 |
+
demo.load(
|
| 402 |
+
handle_update_setup,
|
| 403 |
+
inputs=setup_inputs,
|
| 404 |
+
outputs=outputs,
|
| 405 |
+
show_progress="hidden",
|
| 406 |
+
)
|
| 407 |
+
update_setup.click(handle_update_setup, inputs=setup_inputs, outputs=outputs)
|
| 408 |
+
generate.click(handle_generate, inputs=setup_inputs, outputs=outputs)
|
| 409 |
+
grade.click(handle_grade, inputs=[state_store, stage_id], outputs=outputs)
|
| 410 |
+
save_edits.click(
|
| 411 |
+
handle_save_edits,
|
| 412 |
+
inputs=[
|
| 413 |
+
state_store,
|
| 414 |
+
stage_id,
|
| 415 |
+
artifact_editor,
|
| 416 |
+
reviewer_name,
|
| 417 |
+
reviewer_summary,
|
| 418 |
+
requested_changes_json,
|
| 419 |
+
],
|
| 420 |
+
outputs=outputs,
|
| 421 |
+
)
|
| 422 |
+
improve.click(handle_improve, inputs=[state_store, stage_id], outputs=outputs)
|
| 423 |
+
approve.click(handle_approve, inputs=[state_store, stage_id, reviewer_name], outputs=outputs)
|
| 424 |
+
preflight.click(handle_preflight, inputs=[state_store, stage_id], outputs=outputs)
|
| 425 |
+
export.click(handle_export, inputs=[state_store, stage_id], outputs=outputs)
|
| 426 |
+
stage_id.change(handle_stage_change, inputs=[state_store, stage_id], outputs=outputs)
|
| 427 |
+
|
| 428 |
+
return demo
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
demo = build_app()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
if __name__ == "__main__":
|
| 435 |
+
demo.launch(css=CSS)
|
course_slide_factory/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Course Slide Factory application package."""
|
| 2 |
+
|
course_slide_factory/compat.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, TypeVar
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def model_to_dict(model: BaseModel) -> dict[str, Any]:
|
| 13 |
+
if hasattr(model, "model_dump"):
|
| 14 |
+
return model.model_dump(mode="json") # type: ignore[attr-defined]
|
| 15 |
+
return json.loads(model.json())
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def model_validate(model_cls: type[ModelT], data: Any) -> ModelT:
|
| 19 |
+
if hasattr(model_cls, "model_validate"):
|
| 20 |
+
return model_cls.model_validate(data) # type: ignore[attr-defined]
|
| 21 |
+
return model_cls.parse_obj(data)
|
| 22 |
+
|
course_slide_factory/constants.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
STAGE_SEQUENCE: list[tuple[str, str]] = [
|
| 4 |
+
("setup_inputs", "Setup & Inputs"),
|
| 5 |
+
("source_extraction_objective_mapping", "Source Extraction / Objective Mapping"),
|
| 6 |
+
("slide_outline_order", "Slide Outline & Order"),
|
| 7 |
+
("title_generation", "Title Generation"),
|
| 8 |
+
("text_generation", "Text Generation"),
|
| 9 |
+
("image_visual_asset_generation", "Image / Visual Asset Generation"),
|
| 10 |
+
("aesthetic_ordering_visual_composition", "Aesthetic Ordering / Visual Composition"),
|
| 11 |
+
("technical_review", "Technical Review"),
|
| 12 |
+
("pedagogical_review", "Pedagogical Review"),
|
| 13 |
+
("aesthetic_review", "Aesthetic Review"),
|
| 14 |
+
("final_render_export", "Final Render & Export"),
|
| 15 |
+
("audit_log_version_history", "Audit Log / Version History"),
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
STAGE_IDS = [stage_id for stage_id, _label in STAGE_SEQUENCE]
|
| 19 |
+
STAGE_LABELS = dict(STAGE_SEQUENCE)
|
| 20 |
+
|
| 21 |
+
APPROVAL_REQUIRED_STAGE_IDS = STAGE_IDS[:10]
|
| 22 |
+
|
| 23 |
+
STAGE_RUBRICS: dict[str, list[tuple[str, str, float]]] = {
|
| 24 |
+
"setup_inputs": [
|
| 25 |
+
("required_inputs_present", "Required inputs present", 1.4),
|
| 26 |
+
("url_validity", "URL validity", 1.0),
|
| 27 |
+
("render_mode_safety", "Render mode safety", 1.0),
|
| 28 |
+
],
|
| 29 |
+
"source_extraction_objective_mapping": [
|
| 30 |
+
("source_extraction_completeness", "Source extraction completeness", 1.0),
|
| 31 |
+
("objective_clarity", "Objective clarity", 1.1),
|
| 32 |
+
("objective_uniqueness", "Objective uniqueness", 0.8),
|
| 33 |
+
],
|
| 34 |
+
"slide_outline_order": [
|
| 35 |
+
("objective_coverage", "Objective coverage", 1.4),
|
| 36 |
+
("logical_flow", "Logical flow", 1.0),
|
| 37 |
+
("appropriate_slide_count", "Appropriate slide count", 0.8),
|
| 38 |
+
("no_major_redundancy", "No major redundancy", 0.8),
|
| 39 |
+
],
|
| 40 |
+
"title_generation": [
|
| 41 |
+
("title_clarity", "Title clarity", 1.1),
|
| 42 |
+
("title_specificity", "Title specificity", 1.0),
|
| 43 |
+
("objective_alignment", "Objective alignment", 1.2),
|
| 44 |
+
],
|
| 45 |
+
"text_generation": [
|
| 46 |
+
("clarity", "Clarity", 1.0),
|
| 47 |
+
("text_density", "Text density", 1.3),
|
| 48 |
+
("source_grounding", "Source grounding", 1.3),
|
| 49 |
+
("speaker_notes_quality", "Speaker notes quality", 1.0),
|
| 50 |
+
],
|
| 51 |
+
"image_visual_asset_generation": [
|
| 52 |
+
("visual_relevance", "Visual relevance", 1.0),
|
| 53 |
+
("asset_completeness", "Asset completeness", 1.3),
|
| 54 |
+
("alt_text_presence", "Alt text presence", 1.1),
|
| 55 |
+
("license_or_generation_metadata", "License or generation metadata", 1.0),
|
| 56 |
+
],
|
| 57 |
+
"aesthetic_ordering_visual_composition": [
|
| 58 |
+
("layout_schema_validity", "Layout schema validity", 1.4),
|
| 59 |
+
("slot_compliance", "Slot compliance", 1.1),
|
| 60 |
+
("visual_hierarchy", "Visual hierarchy", 0.8),
|
| 61 |
+
("readability", "Readability", 1.0),
|
| 62 |
+
],
|
| 63 |
+
"technical_review": [
|
| 64 |
+
("technical_accuracy", "Technical accuracy", 1.3),
|
| 65 |
+
("unsupported_claim_detection", "Unsupported claim detection", 1.5),
|
| 66 |
+
("terminology_consistency", "Terminology consistency", 0.8),
|
| 67 |
+
],
|
| 68 |
+
"pedagogical_review": [
|
| 69 |
+
("objective_coverage", "Objective coverage", 1.4),
|
| 70 |
+
("learning_progression", "Learning progression", 1.0),
|
| 71 |
+
("examples_or_applications", "Examples or applications", 0.8),
|
| 72 |
+
("speaker_notes_teachability", "Speaker notes teachability", 1.1),
|
| 73 |
+
],
|
| 74 |
+
"aesthetic_review": [
|
| 75 |
+
("layout_consistency", "Layout consistency", 1.1),
|
| 76 |
+
("readability", "Readability", 1.0),
|
| 77 |
+
("accessibility", "Accessibility", 1.3),
|
| 78 |
+
("brand_template_compliance", "Brand/template compliance", 1.0),
|
| 79 |
+
],
|
| 80 |
+
"final_render_export": [
|
| 81 |
+
("preflight_passed", "Preflight passed", 1.6),
|
| 82 |
+
("all_required_approvals_valid", "All required approvals valid", 1.2),
|
| 83 |
+
("no_stale_stages", "No stale stages", 1.1),
|
| 84 |
+
("export_target_safety", "Export target safety", 1.0),
|
| 85 |
+
],
|
| 86 |
+
"audit_log_version_history": [
|
| 87 |
+
("audit_events_present", "Audit events present", 1.0),
|
| 88 |
+
("artifact_versions_traceable", "Artifact versions traceable", 1.2),
|
| 89 |
+
("review_notes_traceable", "Reviewer notes traceable", 0.8),
|
| 90 |
+
],
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
TEXT_DENSITY_LIMITS: dict[str, dict[str, int]] = {
|
| 94 |
+
"intro": {"max_visible_words": 35, "max_bullets": 0},
|
| 95 |
+
"motivation": {"max_visible_words": 55, "max_bullets": 3},
|
| 96 |
+
"concept": {"max_visible_words": 65, "max_bullets": 4},
|
| 97 |
+
"worked_example": {"max_visible_words": 90, "max_bullets": 5},
|
| 98 |
+
"procedure": {"max_visible_words": 90, "max_bullets": 6},
|
| 99 |
+
"comparison": {"max_visible_words": 85, "max_bullets": 5},
|
| 100 |
+
"common_misconception": {"max_visible_words": 75, "max_bullets": 4},
|
| 101 |
+
"knowledge_check": {"max_visible_words": 100, "max_bullets": 6},
|
| 102 |
+
"demo_setup": {"max_visible_words": 80, "max_bullets": 5},
|
| 103 |
+
"demo_walkthrough": {"max_visible_words": 90, "max_bullets": 6},
|
| 104 |
+
"summary": {"max_visible_words": 70, "max_bullets": 5},
|
| 105 |
+
"transition": {"max_visible_words": 25, "max_bullets": 0},
|
| 106 |
+
"unknown": {"max_visible_words": 65, "max_bullets": 4},
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
INSTRUCTIONAL_ROLES = {
|
| 110 |
+
"concept",
|
| 111 |
+
"worked_example",
|
| 112 |
+
"procedure",
|
| 113 |
+
"comparison",
|
| 114 |
+
"common_misconception",
|
| 115 |
+
"knowledge_check",
|
| 116 |
+
"demo_setup",
|
| 117 |
+
"demo_walkthrough",
|
| 118 |
+
"summary",
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
APPROVED_LAYOUTS: dict[str, dict[str, object]] = {
|
| 122 |
+
"title_only": {
|
| 123 |
+
"required_slots": {"title"},
|
| 124 |
+
"optional_slots": set(),
|
| 125 |
+
"slot_word_limits": {"title": 14},
|
| 126 |
+
},
|
| 127 |
+
"title_body": {
|
| 128 |
+
"required_slots": {"title", "body"},
|
| 129 |
+
"optional_slots": {"subtitle"},
|
| 130 |
+
"slot_word_limits": {"title": 14, "body": 80, "subtitle": 24},
|
| 131 |
+
},
|
| 132 |
+
"title_bullets_visual": {
|
| 133 |
+
"required_slots": {"title", "bullets", "visual"},
|
| 134 |
+
"optional_slots": {"caption"},
|
| 135 |
+
"slot_word_limits": {"title": 14, "bullets": 90, "visual": 8, "caption": 18},
|
| 136 |
+
},
|
| 137 |
+
"worked_example": {
|
| 138 |
+
"required_slots": {"title", "problem", "steps"},
|
| 139 |
+
"optional_slots": {"visual", "answer"},
|
| 140 |
+
"slot_word_limits": {"title": 14, "problem": 45, "steps": 100, "visual": 8, "answer": 30},
|
| 141 |
+
},
|
| 142 |
+
"knowledge_check": {
|
| 143 |
+
"required_slots": {"title", "question", "options"},
|
| 144 |
+
"optional_slots": {"answer"},
|
| 145 |
+
"slot_word_limits": {"title": 14, "question": 55, "options": 70, "answer": 28},
|
| 146 |
+
},
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
RAW_COORDINATE_KEYS = {"x", "y", "width", "height", "left", "top", "w", "h"}
|
| 150 |
+
|
course_slide_factory/fixtures.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from copy import deepcopy
|
| 4 |
+
|
| 5 |
+
from .constants import APPROVAL_REQUIRED_STAGE_IDS
|
| 6 |
+
from .compat import model_to_dict, model_validate
|
| 7 |
+
from .models import (
|
| 8 |
+
ArtifactStatus,
|
| 9 |
+
LayoutSpec,
|
| 10 |
+
PedagogicalRole,
|
| 11 |
+
Slide,
|
| 12 |
+
SlideClaim,
|
| 13 |
+
SpeakerNotes,
|
| 14 |
+
VisualAsset,
|
| 15 |
+
)
|
| 16 |
+
from .quality import approve_current_artifact, create_artifact_version, grade_stage
|
| 17 |
+
from .workflow import build_empty_state, record_prompt_run
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def valid_minimal_job():
|
| 21 |
+
state = build_empty_state(
|
| 22 |
+
deck_title="Intro to Deterministic Slide QA",
|
| 23 |
+
source_url="mock://source/course-notes",
|
| 24 |
+
template_url="mock://template/course",
|
| 25 |
+
dry_run=True,
|
| 26 |
+
)
|
| 27 |
+
state.source_chunks = {
|
| 28 |
+
"chunk_1": "Deterministic slide QA requires objective mapping and supported claims.",
|
| 29 |
+
"chunk_2": "A worked example helps learners apply the concept.",
|
| 30 |
+
}
|
| 31 |
+
state.objectives = {
|
| 32 |
+
"obj_1": "Explain deterministic slide quality checks.",
|
| 33 |
+
"obj_2": "Apply quality checks to a worked example.",
|
| 34 |
+
}
|
| 35 |
+
state.slides = {
|
| 36 |
+
"slide_1": Slide(
|
| 37 |
+
slide_id="slide_1",
|
| 38 |
+
slide_number=1,
|
| 39 |
+
title="Deterministic Slide QA",
|
| 40 |
+
visible_text="Quality checks make slide readiness explicit.",
|
| 41 |
+
bullet_points=["Trace objectives", "Check evidence", "Validate layout"],
|
| 42 |
+
objective_ids=["obj_1"],
|
| 43 |
+
pedagogical_role=PedagogicalRole.CONCEPT,
|
| 44 |
+
requires_visual=True,
|
| 45 |
+
speaker_notes=SpeakerNotes(
|
| 46 |
+
slide_id="slide_1",
|
| 47 |
+
notes_text="Explain why readiness must be explicit before export.",
|
| 48 |
+
instructor_intent="Connect quality gates to production trust.",
|
| 49 |
+
estimated_teaching_time_seconds=180,
|
| 50 |
+
),
|
| 51 |
+
),
|
| 52 |
+
"slide_2": Slide(
|
| 53 |
+
slide_id="slide_2",
|
| 54 |
+
slide_number=2,
|
| 55 |
+
title="Worked QA Example",
|
| 56 |
+
visible_text="Use the checklist to evaluate one generated slide.",
|
| 57 |
+
bullet_points=["Map objective", "Inspect claim support", "Confirm layout"],
|
| 58 |
+
objective_ids=["obj_2"],
|
| 59 |
+
pedagogical_role=PedagogicalRole.WORKED_EXAMPLE,
|
| 60 |
+
requires_visual=False,
|
| 61 |
+
speaker_notes=SpeakerNotes(
|
| 62 |
+
slide_id="slide_2",
|
| 63 |
+
notes_text="Walk through each checklist item and ask learners what blocks export.",
|
| 64 |
+
instructor_intent="Make the gating rule concrete.",
|
| 65 |
+
estimated_teaching_time_seconds=240,
|
| 66 |
+
),
|
| 67 |
+
),
|
| 68 |
+
}
|
| 69 |
+
state.claims = {
|
| 70 |
+
"claim_1": SlideClaim(
|
| 71 |
+
claim_id="claim_1",
|
| 72 |
+
slide_id="slide_1",
|
| 73 |
+
claim_text="Quality checks make slide readiness explicit.",
|
| 74 |
+
source_ids=["source_1"],
|
| 75 |
+
source_chunk_ids=["chunk_1"],
|
| 76 |
+
review_status="supported",
|
| 77 |
+
),
|
| 78 |
+
"claim_2": SlideClaim(
|
| 79 |
+
claim_id="claim_2",
|
| 80 |
+
slide_id="slide_2",
|
| 81 |
+
claim_text="A worked example helps learners apply the concept.",
|
| 82 |
+
source_ids=["source_1"],
|
| 83 |
+
source_chunk_ids=["chunk_2"],
|
| 84 |
+
review_status="supported",
|
| 85 |
+
),
|
| 86 |
+
}
|
| 87 |
+
state.visual_assets = {
|
| 88 |
+
"asset_1": VisualAsset(
|
| 89 |
+
asset_id="asset_1",
|
| 90 |
+
slide_id="slide_1",
|
| 91 |
+
asset_type="diagram",
|
| 92 |
+
path_or_url="mock://asset/qa-flow",
|
| 93 |
+
prompt="Simple quality gate diagram",
|
| 94 |
+
purpose="instructional",
|
| 95 |
+
alt_text="Flow from generation through grading, approval, and export.",
|
| 96 |
+
source="mock",
|
| 97 |
+
license_status="generated",
|
| 98 |
+
approved_for_export=True,
|
| 99 |
+
)
|
| 100 |
+
}
|
| 101 |
+
state.layout_specs = {
|
| 102 |
+
"slide_1": LayoutSpec(
|
| 103 |
+
slide_id="slide_1",
|
| 104 |
+
layout_id="title_bullets_visual",
|
| 105 |
+
approved_template_id="default_course_template",
|
| 106 |
+
slot_assignments={
|
| 107 |
+
"title": "Deterministic Slide QA",
|
| 108 |
+
"bullets": ["Trace objectives", "Check evidence", "Validate layout"],
|
| 109 |
+
"visual": "asset_1",
|
| 110 |
+
},
|
| 111 |
+
),
|
| 112 |
+
"slide_2": LayoutSpec(
|
| 113 |
+
slide_id="slide_2",
|
| 114 |
+
layout_id="worked_example",
|
| 115 |
+
approved_template_id="default_course_template",
|
| 116 |
+
slot_assignments={
|
| 117 |
+
"title": "Worked QA Example",
|
| 118 |
+
"problem": "Evaluate one generated slide.",
|
| 119 |
+
"steps": ["Map objective", "Inspect claim support", "Confirm layout"],
|
| 120 |
+
},
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
# Pydantic validates nested dicts assigned above during this explicit round-trip;
|
| 124 |
+
# this explicit round-trip keeps fixture construction terse and typed.
|
| 125 |
+
state = model_validate(type(state), model_to_dict(state))
|
| 126 |
+
|
| 127 |
+
for stage_id in APPROVAL_REQUIRED_STAGE_IDS:
|
| 128 |
+
prompt_run = record_prompt_run(
|
| 129 |
+
state,
|
| 130 |
+
stage_id,
|
| 131 |
+
rendered_prompt=f"Fixture generation for {stage_id}",
|
| 132 |
+
)
|
| 133 |
+
artifact = create_artifact_version(
|
| 134 |
+
state,
|
| 135 |
+
stage_id,
|
| 136 |
+
{"fixture": stage_id},
|
| 137 |
+
created_by="mock",
|
| 138 |
+
status=ArtifactStatus.CANDIDATE,
|
| 139 |
+
prompt_run_id=prompt_run.prompt_run_id,
|
| 140 |
+
mark_downstream_stale=False,
|
| 141 |
+
)
|
| 142 |
+
prompt_run.output_artifact_version_id = artifact.artifact_version_id
|
| 143 |
+
grade_stage(stage_id, state)
|
| 144 |
+
approve_current_artifact(state, stage_id, reviewer_name="fixture_reviewer")
|
| 145 |
+
return state
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def missing_objective_mapping_job():
|
| 149 |
+
state = deepcopy(valid_minimal_job())
|
| 150 |
+
state.slides["slide_2"].objective_ids = []
|
| 151 |
+
return state
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def stale_downstream_job():
|
| 155 |
+
state = deepcopy(valid_minimal_job())
|
| 156 |
+
create_artifact_version(
|
| 157 |
+
state,
|
| 158 |
+
"slide_outline_order",
|
| 159 |
+
{"fixture": "changed outline"},
|
| 160 |
+
created_by="human",
|
| 161 |
+
status=ArtifactStatus.CANDIDATE,
|
| 162 |
+
mark_downstream_stale=True,
|
| 163 |
+
)
|
| 164 |
+
return state
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def invalidated_approval_job():
|
| 168 |
+
state = deepcopy(valid_minimal_job())
|
| 169 |
+
create_artifact_version(
|
| 170 |
+
state,
|
| 171 |
+
"text_generation",
|
| 172 |
+
{"fixture": "human edit after approval"},
|
| 173 |
+
created_by="human",
|
| 174 |
+
status=ArtifactStatus.CANDIDATE,
|
| 175 |
+
mark_downstream_stale=False,
|
| 176 |
+
)
|
| 177 |
+
return state
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def unsupported_claim_job():
|
| 181 |
+
state = deepcopy(valid_minimal_job())
|
| 182 |
+
claim = state.claims["claim_1"]
|
| 183 |
+
claim.review_status = "unsupported"
|
| 184 |
+
claim.source_ids = []
|
| 185 |
+
claim.source_chunk_ids = []
|
| 186 |
+
return state
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def missing_visual_asset_job():
|
| 190 |
+
state = deepcopy(valid_minimal_job())
|
| 191 |
+
state.visual_assets = {}
|
| 192 |
+
return state
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def invalid_layout_job():
|
| 196 |
+
state = deepcopy(valid_minimal_job())
|
| 197 |
+
state.layout_specs["slide_1"] = LayoutSpec(
|
| 198 |
+
slide_id="slide_1",
|
| 199 |
+
layout_id="raw_coordinates",
|
| 200 |
+
approved_template_id=None,
|
| 201 |
+
slot_assignments={"x": 10, "y": 20, "width": 400, "height": 300},
|
| 202 |
+
)
|
| 203 |
+
return state
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def text_density_failure_job():
|
| 207 |
+
state = deepcopy(valid_minimal_job())
|
| 208 |
+
state.slides["slide_1"].visible_text = " ".join(["dense"] * 80)
|
| 209 |
+
state.slides["slide_1"].bullet_points = ["one", "two", "three", "four", "five"]
|
| 210 |
+
return state
|
course_slide_factory/models.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import Any, Literal
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RubricDimensionScore(BaseModel):
|
| 10 |
+
dimension_id: str
|
| 11 |
+
label: str
|
| 12 |
+
score: int
|
| 13 |
+
weight: float = 1.0
|
| 14 |
+
rationale: str | None = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class StageGradeResult(BaseModel):
|
| 18 |
+
stage_id: str
|
| 19 |
+
artifact_version_id: str | None = None
|
| 20 |
+
score: int
|
| 21 |
+
passed_threshold: bool
|
| 22 |
+
rubric_scores: list[RubricDimensionScore] = Field(default_factory=list)
|
| 23 |
+
issue_ids: list[str] = Field(default_factory=list)
|
| 24 |
+
recommended_actions: list[str] = Field(default_factory=list)
|
| 25 |
+
graded_at: str
|
| 26 |
+
grader: Literal["deterministic", "mock_ai", "human", "system"] = "deterministic"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class IssueType(str, Enum):
|
| 30 |
+
MISSING_REQUIRED_INPUT = "missing_required_input"
|
| 31 |
+
SOURCE_EXTRACTION_FAILED = "source_extraction_failed"
|
| 32 |
+
OBJECTIVE_UNCOVERED = "objective_uncovered"
|
| 33 |
+
OBJECTIVE_WEAKLY_COVERED = "objective_weakly_covered"
|
| 34 |
+
UNSUPPORTED_CLAIM = "unsupported_claim"
|
| 35 |
+
TECHNICAL_INACCURACY = "technical_inaccuracy"
|
| 36 |
+
TEXT_DENSITY_EXCEEDED = "text_density_exceeded"
|
| 37 |
+
COGNITIVE_LOAD_HIGH = "cognitive_load_high"
|
| 38 |
+
SPEAKER_NOTES_MISSING = "speaker_notes_missing"
|
| 39 |
+
LAYOUT_SCHEMA_INVALID = "layout_schema_invalid"
|
| 40 |
+
LAYOUT_SLOT_VIOLATION = "layout_slot_violation"
|
| 41 |
+
VISUAL_ASSET_MISSING = "visual_asset_missing"
|
| 42 |
+
VISUAL_ASSET_PURPOSE_MISSING = "visual_asset_purpose_missing"
|
| 43 |
+
ALT_TEXT_MISSING = "alt_text_missing"
|
| 44 |
+
ACCESSIBILITY_CONTRAST_RISK = "accessibility_contrast_risk"
|
| 45 |
+
COPYRIGHT_OR_LICENSE_RISK = "copyright_or_license_risk"
|
| 46 |
+
RENDER_SAFETY_VIOLATION = "render_safety_violation"
|
| 47 |
+
GOOGLE_EXPORT_FAILED = "google_export_failed"
|
| 48 |
+
HUMAN_APPROVAL_MISSING = "human_approval_missing"
|
| 49 |
+
SCORE_BELOW_THRESHOLD = "score_below_threshold"
|
| 50 |
+
STALE_DOWNSTREAM_STAGE = "stale_downstream_stage"
|
| 51 |
+
APPROVAL_INVALIDATED = "approval_invalidated"
|
| 52 |
+
EXPORT_PREFLIGHT_FAILED = "export_preflight_failed"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class IssueSeverity(str, Enum):
|
| 56 |
+
INFO = "info"
|
| 57 |
+
MINOR = "minor"
|
| 58 |
+
MAJOR = "major"
|
| 59 |
+
BLOCKER = "blocker"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class QualityIssue(BaseModel):
|
| 63 |
+
issue_id: str
|
| 64 |
+
issue_type: IssueType
|
| 65 |
+
severity: IssueSeverity
|
| 66 |
+
message: str
|
| 67 |
+
stage_id: str | None = None
|
| 68 |
+
slide_id: str | None = None
|
| 69 |
+
objective_id: str | None = None
|
| 70 |
+
artifact_version_id: str | None = None
|
| 71 |
+
claim_id: str | None = None
|
| 72 |
+
suggested_fix: str | None = None
|
| 73 |
+
resolved: bool = False
|
| 74 |
+
created_at: str
|
| 75 |
+
resolved_at: str | None = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ObjectiveTrace(BaseModel):
|
| 79 |
+
objective_id: str
|
| 80 |
+
objective_text: str
|
| 81 |
+
mapped_slide_ids: list[str] = Field(default_factory=list)
|
| 82 |
+
coverage_score: int = 0
|
| 83 |
+
coverage_status: Literal["uncovered", "weak", "partial", "strong"] = "uncovered"
|
| 84 |
+
evidence: list[str] = Field(default_factory=list)
|
| 85 |
+
issue_ids: list[str] = Field(default_factory=list)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class PedagogicalRole(str, Enum):
|
| 89 |
+
INTRO = "intro"
|
| 90 |
+
MOTIVATION = "motivation"
|
| 91 |
+
CONCEPT = "concept"
|
| 92 |
+
WORKED_EXAMPLE = "worked_example"
|
| 93 |
+
PROCEDURE = "procedure"
|
| 94 |
+
COMPARISON = "comparison"
|
| 95 |
+
COMMON_MISCONCEPTION = "common_misconception"
|
| 96 |
+
KNOWLEDGE_CHECK = "knowledge_check"
|
| 97 |
+
DEMO_SETUP = "demo_setup"
|
| 98 |
+
DEMO_WALKTHROUGH = "demo_walkthrough"
|
| 99 |
+
SUMMARY = "summary"
|
| 100 |
+
TRANSITION = "transition"
|
| 101 |
+
UNKNOWN = "unknown"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class SpeakerNotes(BaseModel):
|
| 105 |
+
slide_id: str
|
| 106 |
+
notes_text: str | None = None
|
| 107 |
+
instructor_intent: str | None = None
|
| 108 |
+
estimated_teaching_time_seconds: int | None = None
|
| 109 |
+
possible_student_confusions: list[str] = Field(default_factory=list)
|
| 110 |
+
teaching_tips: list[str] = Field(default_factory=list)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class SlideClaim(BaseModel):
|
| 114 |
+
claim_id: str
|
| 115 |
+
slide_id: str
|
| 116 |
+
claim_text: str
|
| 117 |
+
source_ids: list[str] = Field(default_factory=list)
|
| 118 |
+
source_chunk_ids: list[str] = Field(default_factory=list)
|
| 119 |
+
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
|
| 120 |
+
review_status: Literal[
|
| 121 |
+
"unreviewed",
|
| 122 |
+
"supported",
|
| 123 |
+
"unsupported",
|
| 124 |
+
"needs_human_review",
|
| 125 |
+
] = "unreviewed"
|
| 126 |
+
issue_ids: list[str] = Field(default_factory=list)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class VisualAsset(BaseModel):
|
| 130 |
+
asset_id: str
|
| 131 |
+
slide_id: str | None = None
|
| 132 |
+
asset_type: Literal[
|
| 133 |
+
"generated_image",
|
| 134 |
+
"diagram",
|
| 135 |
+
"chart",
|
| 136 |
+
"icon",
|
| 137 |
+
"screenshot",
|
| 138 |
+
"placeholder",
|
| 139 |
+
]
|
| 140 |
+
path_or_url: str | None = None
|
| 141 |
+
prompt: str | None = None
|
| 142 |
+
purpose: Literal["instructional", "decorative", "background", "unknown"] = "unknown"
|
| 143 |
+
alt_text: str | None = None
|
| 144 |
+
source: Literal["mock", "generated", "uploaded", "google_drive", "unknown"] = "unknown"
|
| 145 |
+
license_status: Literal[
|
| 146 |
+
"generated",
|
| 147 |
+
"user_provided",
|
| 148 |
+
"unknown",
|
| 149 |
+
"needs_review",
|
| 150 |
+
] = "unknown"
|
| 151 |
+
approved_for_export: bool = False
|
| 152 |
+
issue_ids: list[str] = Field(default_factory=list)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class LayoutSpec(BaseModel):
|
| 156 |
+
slide_id: str
|
| 157 |
+
layout_id: str
|
| 158 |
+
approved_template_id: str | None = None
|
| 159 |
+
slot_assignments: dict[str, Any] = Field(default_factory=dict)
|
| 160 |
+
schema_version: str = "1.0"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ArtifactStatus(str, Enum):
|
| 164 |
+
DRAFT = "draft"
|
| 165 |
+
CANDIDATE = "candidate"
|
| 166 |
+
APPROVED = "approved"
|
| 167 |
+
EXPORTED = "exported"
|
| 168 |
+
STALE = "stale"
|
| 169 |
+
INVALIDATED = "invalidated"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class ArtifactVersion(BaseModel):
|
| 173 |
+
artifact_version_id: str
|
| 174 |
+
artifact_id: str
|
| 175 |
+
stage_id: str
|
| 176 |
+
version_number: int
|
| 177 |
+
created_at: str
|
| 178 |
+
created_by: Literal["system", "human", "ai", "mock"]
|
| 179 |
+
content_hash: str
|
| 180 |
+
status: ArtifactStatus = ArtifactStatus.DRAFT
|
| 181 |
+
parent_artifact_version_ids: list[str] = Field(default_factory=list)
|
| 182 |
+
upstream_stage_versions: dict[str, str] = Field(default_factory=dict)
|
| 183 |
+
prompt_run_id: str | None = None
|
| 184 |
+
is_current: bool = True
|
| 185 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class RequestedChange(BaseModel):
|
| 189 |
+
slide_id: str | None = None
|
| 190 |
+
stage_id: str | None = None
|
| 191 |
+
change_type: Literal[
|
| 192 |
+
"add_example",
|
| 193 |
+
"reduce_text",
|
| 194 |
+
"fix_technical_accuracy",
|
| 195 |
+
"improve_layout",
|
| 196 |
+
"add_visual",
|
| 197 |
+
"remove_visual",
|
| 198 |
+
"add_speaker_notes",
|
| 199 |
+
"improve_objective_alignment",
|
| 200 |
+
"other",
|
| 201 |
+
] = "other"
|
| 202 |
+
priority: Literal["low", "medium", "high", "blocker"] = "medium"
|
| 203 |
+
note: str
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
class ReviewerNotes(BaseModel):
|
| 207 |
+
stage_id: str
|
| 208 |
+
artifact_version_id: str | None = None
|
| 209 |
+
reviewer_name: str = "human_reviewer"
|
| 210 |
+
summary: str | None = None
|
| 211 |
+
requested_changes: list[RequestedChange] = Field(default_factory=list)
|
| 212 |
+
created_at: str
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class PromptRun(BaseModel):
|
| 216 |
+
prompt_run_id: str
|
| 217 |
+
stage_id: str
|
| 218 |
+
artifact_version_id: str | None = None
|
| 219 |
+
provider: Literal["mock", "openai", "anthropic", "gemini", "system"] = "mock"
|
| 220 |
+
model_name: str | None = None
|
| 221 |
+
prompt_template_id: str | None = None
|
| 222 |
+
prompt_template_version: str | None = None
|
| 223 |
+
rendered_prompt: str | None = None
|
| 224 |
+
input_artifact_version_ids: list[str] = Field(default_factory=list)
|
| 225 |
+
output_artifact_version_id: str | None = None
|
| 226 |
+
settings: dict[str, Any] = Field(default_factory=dict)
|
| 227 |
+
created_at: str
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class DeckHealthSummary(BaseModel):
|
| 231 |
+
job_id: str
|
| 232 |
+
deck_title: str | None = None
|
| 233 |
+
approved_stage_count: int
|
| 234 |
+
total_stage_count: int
|
| 235 |
+
stale_stage_count: int
|
| 236 |
+
invalidated_approval_count: int
|
| 237 |
+
unresolved_blocker_count: int
|
| 238 |
+
unresolved_major_issue_count: int
|
| 239 |
+
slide_count: int
|
| 240 |
+
average_slide_score: int | None = None
|
| 241 |
+
objectives_total: int
|
| 242 |
+
objectives_strong: int
|
| 243 |
+
objectives_partial_or_weak: int
|
| 244 |
+
objectives_uncovered: int
|
| 245 |
+
unsupported_claim_count: int
|
| 246 |
+
can_export: bool
|
| 247 |
+
top_blockers: list[str] = Field(default_factory=list)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
class SlideQAStatus(BaseModel):
|
| 251 |
+
slide_id: str
|
| 252 |
+
slide_number: int
|
| 253 |
+
title: str | None = None
|
| 254 |
+
pedagogical_role: PedagogicalRole = PedagogicalRole.UNKNOWN
|
| 255 |
+
objective_ids: list[str] = Field(default_factory=list)
|
| 256 |
+
claim_ids: list[str] = Field(default_factory=list)
|
| 257 |
+
visual_asset_ids: list[str] = Field(default_factory=list)
|
| 258 |
+
layout_id: str | None = None
|
| 259 |
+
aggregate_score: int | None = None
|
| 260 |
+
technical_score: int | None = None
|
| 261 |
+
pedagogical_score: int | None = None
|
| 262 |
+
aesthetic_score: int | None = None
|
| 263 |
+
issue_ids: list[str] = Field(default_factory=list)
|
| 264 |
+
stale: bool = False
|
| 265 |
+
status: Literal["draft", "needs_review", "needs_revision", "approved", "stale", "blocked"] = (
|
| 266 |
+
"draft"
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class ExportPreflightReport(BaseModel):
|
| 271 |
+
can_export: bool
|
| 272 |
+
checked_at: str
|
| 273 |
+
blocking_issue_ids: list[str] = Field(default_factory=list)
|
| 274 |
+
warning_issue_ids: list[str] = Field(default_factory=list)
|
| 275 |
+
summary: str
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class StageApproval(BaseModel):
|
| 279 |
+
stage_id: str
|
| 280 |
+
artifact_version_id: str
|
| 281 |
+
reviewer_name: str = "human_reviewer"
|
| 282 |
+
approved_at: str
|
| 283 |
+
approval_status: Literal["approved", "invalidated", "revoked"] = "approved"
|
| 284 |
+
invalidated_at: str | None = None
|
| 285 |
+
invalidation_reason: str | None = None
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
class AuditEvent(BaseModel):
|
| 289 |
+
event_id: str
|
| 290 |
+
event_type: str
|
| 291 |
+
timestamp: str
|
| 292 |
+
stage_id: str | None = None
|
| 293 |
+
slide_id: str | None = None
|
| 294 |
+
objective_id: str | None = None
|
| 295 |
+
artifact_version_id: str | None = None
|
| 296 |
+
issue_id: str | None = None
|
| 297 |
+
claim_id: str | None = None
|
| 298 |
+
reason: str | None = None
|
| 299 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class StageState(BaseModel):
|
| 303 |
+
stage_id: str
|
| 304 |
+
label: str
|
| 305 |
+
score: int | None = None
|
| 306 |
+
is_stale: bool = False
|
| 307 |
+
current_artifact_version_id: str | None = None
|
| 308 |
+
grade_result: StageGradeResult | None = None
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
class Slide(BaseModel):
|
| 312 |
+
slide_id: str
|
| 313 |
+
slide_number: int
|
| 314 |
+
title: str | None = None
|
| 315 |
+
visible_text: str = ""
|
| 316 |
+
bullet_points: list[str] = Field(default_factory=list)
|
| 317 |
+
objective_ids: list[str] = Field(default_factory=list)
|
| 318 |
+
pedagogical_role: PedagogicalRole = PedagogicalRole.UNKNOWN
|
| 319 |
+
requires_visual: bool = False
|
| 320 |
+
speaker_notes: SpeakerNotes | None = None
|
| 321 |
+
objective_coverage_scores: dict[str, int] = Field(default_factory=dict)
|
| 322 |
+
distinct_concept_count: int | None = None
|
| 323 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
class PipelineState(BaseModel):
|
| 327 |
+
job_id: str
|
| 328 |
+
deck_title: str | None = None
|
| 329 |
+
dry_run: bool = True
|
| 330 |
+
production_export_requested: bool = False
|
| 331 |
+
source_url: str | None = None
|
| 332 |
+
template_url: str | None = None
|
| 333 |
+
output_folder_id: str | None = None
|
| 334 |
+
mutation_target_url: str | None = None
|
| 335 |
+
source_chunks: dict[str, str] = Field(default_factory=dict)
|
| 336 |
+
objectives: dict[str, str] = Field(default_factory=dict)
|
| 337 |
+
stages: dict[str, StageState] = Field(default_factory=dict)
|
| 338 |
+
artifacts: dict[str, ArtifactVersion] = Field(default_factory=dict)
|
| 339 |
+
stage_artifact_versions: dict[str, list[str]] = Field(default_factory=dict)
|
| 340 |
+
issues: dict[str, QualityIssue] = Field(default_factory=dict)
|
| 341 |
+
objective_traces: dict[str, ObjectiveTrace] = Field(default_factory=dict)
|
| 342 |
+
slides: dict[str, Slide] = Field(default_factory=dict)
|
| 343 |
+
slide_statuses: dict[str, SlideQAStatus] = Field(default_factory=dict)
|
| 344 |
+
claims: dict[str, SlideClaim] = Field(default_factory=dict)
|
| 345 |
+
visual_assets: dict[str, VisualAsset] = Field(default_factory=dict)
|
| 346 |
+
layout_specs: dict[str, LayoutSpec] = Field(default_factory=dict)
|
| 347 |
+
approvals: list[StageApproval] = Field(default_factory=list)
|
| 348 |
+
reviewer_notes: list[ReviewerNotes] = Field(default_factory=list)
|
| 349 |
+
prompt_runs: dict[str, PromptRun] = Field(default_factory=dict)
|
| 350 |
+
audit_events: list[AuditEvent] = Field(default_factory=list)
|
| 351 |
+
export_preflight_report: ExportPreflightReport | None = None
|
course_slide_factory/quality.py
ADDED
|
@@ -0,0 +1,1451 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import difflib
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from datetime import UTC, datetime
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .constants import (
|
| 11 |
+
APPROVAL_REQUIRED_STAGE_IDS,
|
| 12 |
+
APPROVED_LAYOUTS,
|
| 13 |
+
INSTRUCTIONAL_ROLES,
|
| 14 |
+
RAW_COORDINATE_KEYS,
|
| 15 |
+
STAGE_IDS,
|
| 16 |
+
STAGE_RUBRICS,
|
| 17 |
+
TEXT_DENSITY_LIMITS,
|
| 18 |
+
)
|
| 19 |
+
from .models import (
|
| 20 |
+
ArtifactStatus,
|
| 21 |
+
ArtifactVersion,
|
| 22 |
+
AuditEvent,
|
| 23 |
+
DeckHealthSummary,
|
| 24 |
+
ExportPreflightReport,
|
| 25 |
+
IssueSeverity,
|
| 26 |
+
IssueType,
|
| 27 |
+
LayoutSpec,
|
| 28 |
+
ObjectiveTrace,
|
| 29 |
+
PipelineState,
|
| 30 |
+
QualityIssue,
|
| 31 |
+
RubricDimensionScore,
|
| 32 |
+
SlideQAStatus,
|
| 33 |
+
StageApproval,
|
| 34 |
+
StageGradeResult,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
PASSING_SCORE = 80
|
| 39 |
+
SLIDE_REVIEW_STAGE_IDS = {
|
| 40 |
+
"text_generation",
|
| 41 |
+
"image_visual_asset_generation",
|
| 42 |
+
"aesthetic_ordering_visual_composition",
|
| 43 |
+
"technical_review",
|
| 44 |
+
"pedagogical_review",
|
| 45 |
+
"aesthetic_review",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def now_iso() -> str:
|
| 50 |
+
return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def clamp_score(score: float) -> int:
|
| 54 |
+
return max(0, min(100, int(round(score))))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def stable_hash(content: Any) -> str:
|
| 58 |
+
rendered = json.dumps(content, sort_keys=True, default=str)
|
| 59 |
+
return hashlib.sha256(rendered.encode("utf-8")).hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def record_audit(
|
| 63 |
+
state: PipelineState,
|
| 64 |
+
event_type: str,
|
| 65 |
+
*,
|
| 66 |
+
stage_id: str | None = None,
|
| 67 |
+
slide_id: str | None = None,
|
| 68 |
+
objective_id: str | None = None,
|
| 69 |
+
artifact_version_id: str | None = None,
|
| 70 |
+
issue_id: str | None = None,
|
| 71 |
+
claim_id: str | None = None,
|
| 72 |
+
reason: str | None = None,
|
| 73 |
+
metadata: dict[str, Any] | None = None,
|
| 74 |
+
) -> AuditEvent:
|
| 75 |
+
event = AuditEvent(
|
| 76 |
+
event_id=f"evt_{len(state.audit_events) + 1:05d}",
|
| 77 |
+
event_type=event_type,
|
| 78 |
+
timestamp=now_iso(),
|
| 79 |
+
stage_id=stage_id,
|
| 80 |
+
slide_id=slide_id,
|
| 81 |
+
objective_id=objective_id,
|
| 82 |
+
artifact_version_id=artifact_version_id,
|
| 83 |
+
issue_id=issue_id,
|
| 84 |
+
claim_id=claim_id,
|
| 85 |
+
reason=reason,
|
| 86 |
+
metadata=metadata or {},
|
| 87 |
+
)
|
| 88 |
+
state.audit_events.append(event)
|
| 89 |
+
return event
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def aggregate_rubric_score(rubric_scores: list[RubricDimensionScore]) -> int:
|
| 93 |
+
if not rubric_scores:
|
| 94 |
+
return 0
|
| 95 |
+
total_weight = sum(max(0.0, item.weight) for item in rubric_scores)
|
| 96 |
+
if total_weight <= 0:
|
| 97 |
+
return 0
|
| 98 |
+
weighted = sum(clamp_score(item.score) * max(0.0, item.weight) for item in rubric_scores)
|
| 99 |
+
return clamp_score(weighted / total_weight)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def issue_id_for(
|
| 103 |
+
issue_type: IssueType,
|
| 104 |
+
*,
|
| 105 |
+
stage_id: str | None = None,
|
| 106 |
+
slide_id: str | None = None,
|
| 107 |
+
objective_id: str | None = None,
|
| 108 |
+
claim_id: str | None = None,
|
| 109 |
+
artifact_version_id: str | None = None,
|
| 110 |
+
) -> str:
|
| 111 |
+
parts = [
|
| 112 |
+
issue_type.value,
|
| 113 |
+
stage_id or "deck",
|
| 114 |
+
slide_id or "all_slides",
|
| 115 |
+
objective_id or "all_objectives",
|
| 116 |
+
claim_id or "all_claims",
|
| 117 |
+
artifact_version_id or "current",
|
| 118 |
+
]
|
| 119 |
+
safe = [re.sub(r"[^a-zA-Z0-9_.-]+", "_", part) for part in parts]
|
| 120 |
+
return "issue:" + ":".join(safe)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def upsert_issue(
|
| 124 |
+
state: PipelineState,
|
| 125 |
+
issue_type: IssueType,
|
| 126 |
+
severity: IssueSeverity,
|
| 127 |
+
message: str,
|
| 128 |
+
*,
|
| 129 |
+
stage_id: str | None = None,
|
| 130 |
+
slide_id: str | None = None,
|
| 131 |
+
objective_id: str | None = None,
|
| 132 |
+
artifact_version_id: str | None = None,
|
| 133 |
+
claim_id: str | None = None,
|
| 134 |
+
suggested_fix: str | None = None,
|
| 135 |
+
) -> QualityIssue:
|
| 136 |
+
issue_id = issue_id_for(
|
| 137 |
+
issue_type,
|
| 138 |
+
stage_id=stage_id,
|
| 139 |
+
slide_id=slide_id,
|
| 140 |
+
objective_id=objective_id,
|
| 141 |
+
claim_id=claim_id,
|
| 142 |
+
artifact_version_id=artifact_version_id,
|
| 143 |
+
)
|
| 144 |
+
existing = state.issues.get(issue_id)
|
| 145 |
+
if existing is None:
|
| 146 |
+
issue = QualityIssue(
|
| 147 |
+
issue_id=issue_id,
|
| 148 |
+
issue_type=issue_type,
|
| 149 |
+
severity=severity,
|
| 150 |
+
message=message,
|
| 151 |
+
stage_id=stage_id,
|
| 152 |
+
slide_id=slide_id,
|
| 153 |
+
objective_id=objective_id,
|
| 154 |
+
artifact_version_id=artifact_version_id,
|
| 155 |
+
claim_id=claim_id,
|
| 156 |
+
suggested_fix=suggested_fix,
|
| 157 |
+
created_at=now_iso(),
|
| 158 |
+
)
|
| 159 |
+
state.issues[issue_id] = issue
|
| 160 |
+
record_audit(
|
| 161 |
+
state,
|
| 162 |
+
"issue_created",
|
| 163 |
+
stage_id=stage_id,
|
| 164 |
+
slide_id=slide_id,
|
| 165 |
+
objective_id=objective_id,
|
| 166 |
+
artifact_version_id=artifact_version_id,
|
| 167 |
+
issue_id=issue_id,
|
| 168 |
+
claim_id=claim_id,
|
| 169 |
+
reason=message,
|
| 170 |
+
)
|
| 171 |
+
return issue
|
| 172 |
+
|
| 173 |
+
existing.severity = severity
|
| 174 |
+
existing.message = message
|
| 175 |
+
existing.suggested_fix = suggested_fix
|
| 176 |
+
existing.resolved = False
|
| 177 |
+
existing.resolved_at = None
|
| 178 |
+
return existing
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def resolve_issue(state: PipelineState, issue_id: str) -> None:
|
| 182 |
+
issue = state.issues.get(issue_id)
|
| 183 |
+
if issue and not issue.resolved:
|
| 184 |
+
issue.resolved = True
|
| 185 |
+
issue.resolved_at = now_iso()
|
| 186 |
+
record_audit(state, "issue_resolved", issue_id=issue_id, reason="Condition cleared")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def unresolved_issues(state: PipelineState) -> list[QualityIssue]:
|
| 190 |
+
return [issue for issue in state.issues.values() if not issue.resolved]
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def unresolved_stage_issues(stage_id: str, state: PipelineState) -> list[QualityIssue]:
|
| 194 |
+
return [issue for issue in unresolved_issues(state) if issue.stage_id == stage_id]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def has_unresolved_blockers(stage_id: str, state: PipelineState) -> bool:
|
| 198 |
+
return any(
|
| 199 |
+
issue.severity == IssueSeverity.BLOCKER for issue in unresolved_stage_issues(stage_id, state)
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def has_unresolved_blockers_anywhere(state: PipelineState) -> bool:
|
| 204 |
+
return any(issue.severity == IssueSeverity.BLOCKER for issue in unresolved_issues(state))
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_current_stage_artifact(stage_id: str, state: PipelineState) -> ArtifactVersion | None:
|
| 208 |
+
stage = state.stages.get(stage_id)
|
| 209 |
+
if stage and stage.current_artifact_version_id:
|
| 210 |
+
artifact = state.artifacts.get(stage.current_artifact_version_id)
|
| 211 |
+
if artifact and artifact.is_current:
|
| 212 |
+
return artifact
|
| 213 |
+
for artifact_id in reversed(state.stage_artifact_versions.get(stage_id, [])):
|
| 214 |
+
artifact = state.artifacts.get(artifact_id)
|
| 215 |
+
if artifact and artifact.is_current:
|
| 216 |
+
return artifact
|
| 217 |
+
return None
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def create_artifact_version(
|
| 221 |
+
state: PipelineState,
|
| 222 |
+
stage_id: str,
|
| 223 |
+
content: Any,
|
| 224 |
+
*,
|
| 225 |
+
created_by: str,
|
| 226 |
+
status: ArtifactStatus = ArtifactStatus.CANDIDATE,
|
| 227 |
+
prompt_run_id: str | None = None,
|
| 228 |
+
mark_downstream_stale: bool = True,
|
| 229 |
+
) -> ArtifactVersion:
|
| 230 |
+
previous = get_current_stage_artifact(stage_id, state)
|
| 231 |
+
if previous:
|
| 232 |
+
previous.is_current = False
|
| 233 |
+
if previous.status in {ArtifactStatus.DRAFT, ArtifactStatus.CANDIDATE}:
|
| 234 |
+
previous.status = ArtifactStatus.INVALIDATED
|
| 235 |
+
|
| 236 |
+
version_number = len(state.stage_artifact_versions.get(stage_id, [])) + 1
|
| 237 |
+
artifact_version_id = f"{stage_id}_v{version_number}"
|
| 238 |
+
artifact = ArtifactVersion(
|
| 239 |
+
artifact_version_id=artifact_version_id,
|
| 240 |
+
artifact_id=stage_id,
|
| 241 |
+
stage_id=stage_id,
|
| 242 |
+
version_number=version_number,
|
| 243 |
+
created_at=now_iso(),
|
| 244 |
+
created_by=created_by, # type: ignore[arg-type]
|
| 245 |
+
content_hash=stable_hash(content),
|
| 246 |
+
status=status,
|
| 247 |
+
parent_artifact_version_ids=[previous.artifact_version_id] if previous else [],
|
| 248 |
+
upstream_stage_versions={
|
| 249 |
+
upstream_id: upstream.current_artifact_version_id
|
| 250 |
+
for upstream_id, upstream in state.stages.items()
|
| 251 |
+
if STAGE_IDS.index(upstream_id) < STAGE_IDS.index(stage_id)
|
| 252 |
+
and upstream.current_artifact_version_id
|
| 253 |
+
},
|
| 254 |
+
prompt_run_id=prompt_run_id,
|
| 255 |
+
metadata={"content": content},
|
| 256 |
+
)
|
| 257 |
+
state.artifacts[artifact_version_id] = artifact
|
| 258 |
+
state.stage_artifact_versions.setdefault(stage_id, []).append(artifact_version_id)
|
| 259 |
+
state.stages[stage_id].current_artifact_version_id = artifact_version_id
|
| 260 |
+
state.stages[stage_id].is_stale = False
|
| 261 |
+
for issue in list(state.issues.values()):
|
| 262 |
+
if issue.stage_id == stage_id and issue.issue_type == IssueType.STALE_DOWNSTREAM_STAGE:
|
| 263 |
+
resolve_issue(state, issue.issue_id)
|
| 264 |
+
record_audit(
|
| 265 |
+
state,
|
| 266 |
+
"artifact_promoted" if status == ArtifactStatus.APPROVED else "artifact_created",
|
| 267 |
+
stage_id=stage_id,
|
| 268 |
+
artifact_version_id=artifact_version_id,
|
| 269 |
+
metadata={"status": status.value, "created_by": created_by},
|
| 270 |
+
)
|
| 271 |
+
invalidate_approval_for_stage(state, stage_id, "Artifact changed after approval")
|
| 272 |
+
if mark_downstream_stale:
|
| 273 |
+
mark_downstream_stages_stale(stage_id, state)
|
| 274 |
+
return artifact
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def invalidate_approval_for_stage(state: PipelineState, stage_id: str, reason: str) -> None:
|
| 278 |
+
for approval in state.approvals:
|
| 279 |
+
if approval.stage_id != stage_id or approval.approval_status != "approved":
|
| 280 |
+
continue
|
| 281 |
+
approval.approval_status = "invalidated"
|
| 282 |
+
approval.invalidated_at = now_iso()
|
| 283 |
+
approval.invalidation_reason = reason
|
| 284 |
+
upsert_issue(
|
| 285 |
+
state,
|
| 286 |
+
IssueType.APPROVAL_INVALIDATED,
|
| 287 |
+
IssueSeverity.BLOCKER,
|
| 288 |
+
f"Approval for {stage_id} was invalidated: {reason}",
|
| 289 |
+
stage_id=stage_id,
|
| 290 |
+
artifact_version_id=approval.artifact_version_id,
|
| 291 |
+
suggested_fix="Re-grade and approve the current artifact version.",
|
| 292 |
+
)
|
| 293 |
+
record_audit(
|
| 294 |
+
state,
|
| 295 |
+
"approval_invalidated",
|
| 296 |
+
stage_id=stage_id,
|
| 297 |
+
artifact_version_id=approval.artifact_version_id,
|
| 298 |
+
reason=reason,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def has_valid_human_approval(stage_id: str, state: PipelineState) -> bool:
|
| 303 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 304 |
+
if artifact is None:
|
| 305 |
+
return False
|
| 306 |
+
if artifact.status not in {ArtifactStatus.APPROVED, ArtifactStatus.EXPORTED}:
|
| 307 |
+
return False
|
| 308 |
+
return any(
|
| 309 |
+
approval.stage_id == stage_id
|
| 310 |
+
and approval.artifact_version_id == artifact.artifact_version_id
|
| 311 |
+
and approval.approval_status == "approved"
|
| 312 |
+
for approval in state.approvals
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def approve_current_artifact(
|
| 317 |
+
state: PipelineState,
|
| 318 |
+
stage_id: str,
|
| 319 |
+
reviewer_name: str = "human_reviewer",
|
| 320 |
+
) -> StageApproval:
|
| 321 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 322 |
+
if artifact is None:
|
| 323 |
+
raise ValueError(f"Cannot approve {stage_id}: current artifact is missing.")
|
| 324 |
+
artifact.status = ArtifactStatus.APPROVED
|
| 325 |
+
approval = StageApproval(
|
| 326 |
+
stage_id=stage_id,
|
| 327 |
+
artifact_version_id=artifact.artifact_version_id,
|
| 328 |
+
reviewer_name=reviewer_name or "human_reviewer",
|
| 329 |
+
approved_at=now_iso(),
|
| 330 |
+
)
|
| 331 |
+
state.approvals.append(approval)
|
| 332 |
+
record_audit(
|
| 333 |
+
state,
|
| 334 |
+
"approval_created",
|
| 335 |
+
stage_id=stage_id,
|
| 336 |
+
artifact_version_id=artifact.artifact_version_id,
|
| 337 |
+
metadata={"reviewer_name": approval.reviewer_name},
|
| 338 |
+
)
|
| 339 |
+
record_audit(
|
| 340 |
+
state,
|
| 341 |
+
"artifact_promoted",
|
| 342 |
+
stage_id=stage_id,
|
| 343 |
+
artifact_version_id=artifact.artifact_version_id,
|
| 344 |
+
metadata={"status": artifact.status.value},
|
| 345 |
+
)
|
| 346 |
+
return approval
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def mark_downstream_stages_stale(changed_stage_id: str, state: PipelineState) -> PipelineState:
|
| 350 |
+
if changed_stage_id not in STAGE_IDS:
|
| 351 |
+
return state
|
| 352 |
+
changed_index = STAGE_IDS.index(changed_stage_id)
|
| 353 |
+
for stage_id in STAGE_IDS[changed_index + 1 :]:
|
| 354 |
+
stage = state.stages[stage_id]
|
| 355 |
+
stage.is_stale = True
|
| 356 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 357 |
+
artifact_version_id = None
|
| 358 |
+
if artifact is not None:
|
| 359 |
+
artifact.status = ArtifactStatus.STALE
|
| 360 |
+
artifact_version_id = artifact.artifact_version_id
|
| 361 |
+
record_audit(
|
| 362 |
+
state,
|
| 363 |
+
"artifact_marked_stale",
|
| 364 |
+
stage_id=stage_id,
|
| 365 |
+
artifact_version_id=artifact.artifact_version_id,
|
| 366 |
+
reason=f"Upstream stage {changed_stage_id} changed",
|
| 367 |
+
)
|
| 368 |
+
invalidate_approval_for_stage(
|
| 369 |
+
state,
|
| 370 |
+
stage_id,
|
| 371 |
+
f"Upstream stage {changed_stage_id} changed",
|
| 372 |
+
)
|
| 373 |
+
upsert_issue(
|
| 374 |
+
state,
|
| 375 |
+
IssueType.STALE_DOWNSTREAM_STAGE,
|
| 376 |
+
IssueSeverity.BLOCKER,
|
| 377 |
+
f"{stage_id} is stale because {changed_stage_id} changed.",
|
| 378 |
+
stage_id=stage_id,
|
| 379 |
+
artifact_version_id=artifact_version_id,
|
| 380 |
+
suggested_fix="Regenerate or re-grade this stage against the current upstream artifacts.",
|
| 381 |
+
)
|
| 382 |
+
record_audit(
|
| 383 |
+
state,
|
| 384 |
+
"stage_marked_stale",
|
| 385 |
+
stage_id=stage_id,
|
| 386 |
+
reason=f"Upstream stage {changed_stage_id} changed",
|
| 387 |
+
)
|
| 388 |
+
return state
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def can_unlock_next_stage(stage_id: str, state: PipelineState) -> bool:
|
| 392 |
+
stage = state.stages[stage_id]
|
| 393 |
+
if stage.score is None or stage.score < PASSING_SCORE:
|
| 394 |
+
return False
|
| 395 |
+
if has_unresolved_blockers(stage_id, state):
|
| 396 |
+
return False
|
| 397 |
+
if stage.is_stale:
|
| 398 |
+
return False
|
| 399 |
+
if not has_valid_human_approval(stage_id, state):
|
| 400 |
+
return False
|
| 401 |
+
current_artifact = get_current_stage_artifact(stage_id, state)
|
| 402 |
+
if current_artifact is None:
|
| 403 |
+
return False
|
| 404 |
+
return current_artifact.status in {ArtifactStatus.APPROVED, ArtifactStatus.EXPORTED}
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def get_stage_lock_reasons(stage_id: str, state: PipelineState) -> list[str]:
|
| 408 |
+
reasons: list[str] = []
|
| 409 |
+
stage = state.stages[stage_id]
|
| 410 |
+
if stage.is_stale:
|
| 411 |
+
reasons.append("Stage is stale.")
|
| 412 |
+
|
| 413 |
+
if stage_id == STAGE_IDS[0]:
|
| 414 |
+
return reasons
|
| 415 |
+
|
| 416 |
+
previous_stage_id = STAGE_IDS[STAGE_IDS.index(stage_id) - 1]
|
| 417 |
+
previous = state.stages[previous_stage_id]
|
| 418 |
+
if previous.score is None:
|
| 419 |
+
reasons.append("Previous stage has not been scored.")
|
| 420 |
+
elif previous.score < PASSING_SCORE:
|
| 421 |
+
reasons.append("Previous stage score is below 80.")
|
| 422 |
+
|
| 423 |
+
if has_unresolved_blockers(previous_stage_id, state):
|
| 424 |
+
reasons.append("Previous stage has unresolved blockers.")
|
| 425 |
+
|
| 426 |
+
previous_artifact = get_current_stage_artifact(previous_stage_id, state)
|
| 427 |
+
if previous_artifact is None:
|
| 428 |
+
reasons.append("Current artifact is missing.")
|
| 429 |
+
elif previous_artifact.status not in {ArtifactStatus.APPROVED, ArtifactStatus.EXPORTED}:
|
| 430 |
+
reasons.append("Current artifact is not approved.")
|
| 431 |
+
|
| 432 |
+
if not has_valid_human_approval(previous_stage_id, state):
|
| 433 |
+
invalidated = any(
|
| 434 |
+
approval.stage_id == previous_stage_id
|
| 435 |
+
and approval.approval_status == "invalidated"
|
| 436 |
+
for approval in state.approvals
|
| 437 |
+
)
|
| 438 |
+
reasons.append("Human approval was invalidated." if invalidated else "Human approval is missing.")
|
| 439 |
+
|
| 440 |
+
if previous.is_stale:
|
| 441 |
+
reasons.append("Previous stage is stale.")
|
| 442 |
+
return list(dict.fromkeys(reasons))
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _visible_words(text: str) -> int:
|
| 446 |
+
return len(re.findall(r"\b[\w'-]+\b", text))
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def _slide_visible_text(slide_id: str, state: PipelineState) -> str:
|
| 450 |
+
slide = state.slides[slide_id]
|
| 451 |
+
return " ".join([slide.title or "", slide.visible_text, " ".join(slide.bullet_points)]).strip()
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def compute_objective_traces(state: PipelineState) -> list[ObjectiveTrace]:
|
| 455 |
+
traces: list[ObjectiveTrace] = []
|
| 456 |
+
for objective_id, objective_text in state.objectives.items():
|
| 457 |
+
mapped = [
|
| 458 |
+
slide.slide_id
|
| 459 |
+
for slide in state.slides.values()
|
| 460 |
+
if objective_id in slide.objective_ids
|
| 461 |
+
]
|
| 462 |
+
evidence = [
|
| 463 |
+
state.slides[slide_id].title or state.slides[slide_id].visible_text[:80]
|
| 464 |
+
for slide_id in mapped
|
| 465 |
+
]
|
| 466 |
+
issue_ids: list[str] = []
|
| 467 |
+
if not mapped:
|
| 468 |
+
coverage_score = 0
|
| 469 |
+
coverage_status = "uncovered"
|
| 470 |
+
issue = upsert_issue(
|
| 471 |
+
state,
|
| 472 |
+
IssueType.OBJECTIVE_UNCOVERED,
|
| 473 |
+
IssueSeverity.BLOCKER,
|
| 474 |
+
f"Learning objective {objective_id} is not mapped to any slide.",
|
| 475 |
+
stage_id="slide_outline_order",
|
| 476 |
+
objective_id=objective_id,
|
| 477 |
+
suggested_fix="Map this objective to at least one slide.",
|
| 478 |
+
)
|
| 479 |
+
issue_ids.append(issue.issue_id)
|
| 480 |
+
else:
|
| 481 |
+
explicit_scores = [
|
| 482 |
+
state.slides[slide_id].objective_coverage_scores.get(objective_id)
|
| 483 |
+
for slide_id in mapped
|
| 484 |
+
if objective_id in state.slides[slide_id].objective_coverage_scores
|
| 485 |
+
]
|
| 486 |
+
if explicit_scores:
|
| 487 |
+
coverage_score = clamp_score(sum(explicit_scores) / len(explicit_scores))
|
| 488 |
+
elif len(mapped) >= 2:
|
| 489 |
+
coverage_score = 90
|
| 490 |
+
else:
|
| 491 |
+
coverage_score = 75
|
| 492 |
+
|
| 493 |
+
if coverage_score < 50:
|
| 494 |
+
coverage_status = "weak"
|
| 495 |
+
issue = upsert_issue(
|
| 496 |
+
state,
|
| 497 |
+
IssueType.OBJECTIVE_WEAKLY_COVERED,
|
| 498 |
+
IssueSeverity.MAJOR,
|
| 499 |
+
f"Learning objective {objective_id} is only weakly covered.",
|
| 500 |
+
stage_id="slide_outline_order",
|
| 501 |
+
objective_id=objective_id,
|
| 502 |
+
suggested_fix="Add stronger evidence or another mapped slide.",
|
| 503 |
+
)
|
| 504 |
+
issue_ids.append(issue.issue_id)
|
| 505 |
+
elif coverage_score < 80:
|
| 506 |
+
coverage_status = "partial"
|
| 507 |
+
else:
|
| 508 |
+
coverage_status = "strong"
|
| 509 |
+
|
| 510 |
+
trace = ObjectiveTrace(
|
| 511 |
+
objective_id=objective_id,
|
| 512 |
+
objective_text=objective_text,
|
| 513 |
+
mapped_slide_ids=mapped,
|
| 514 |
+
coverage_score=coverage_score,
|
| 515 |
+
coverage_status=coverage_status, # type: ignore[arg-type]
|
| 516 |
+
evidence=evidence,
|
| 517 |
+
issue_ids=issue_ids,
|
| 518 |
+
)
|
| 519 |
+
state.objective_traces[objective_id] = trace
|
| 520 |
+
traces.append(trace)
|
| 521 |
+
record_audit(
|
| 522 |
+
state,
|
| 523 |
+
"objective_trace_updated",
|
| 524 |
+
objective_id=objective_id,
|
| 525 |
+
metadata={"coverage_status": trace.coverage_status, "coverage_score": trace.coverage_score},
|
| 526 |
+
)
|
| 527 |
+
return traces
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def validate_claim_support(state: PipelineState) -> list[QualityIssue]:
|
| 531 |
+
issues: list[QualityIssue] = []
|
| 532 |
+
for claim in state.claims.values():
|
| 533 |
+
unsupported = claim.review_status == "unsupported"
|
| 534 |
+
missing_support = not claim.source_ids and not claim.source_chunk_ids
|
| 535 |
+
if not unsupported and not missing_support:
|
| 536 |
+
continue
|
| 537 |
+
issue = upsert_issue(
|
| 538 |
+
state,
|
| 539 |
+
IssueType.UNSUPPORTED_CLAIM,
|
| 540 |
+
IssueSeverity.BLOCKER,
|
| 541 |
+
f"Claim {claim.claim_id} is not source-grounded.",
|
| 542 |
+
stage_id="technical_review",
|
| 543 |
+
slide_id=claim.slide_id,
|
| 544 |
+
claim_id=claim.claim_id,
|
| 545 |
+
suggested_fix="Attach source chunks or mark the claim supported after review.",
|
| 546 |
+
)
|
| 547 |
+
if issue.issue_id not in claim.issue_ids:
|
| 548 |
+
claim.issue_ids.append(issue.issue_id)
|
| 549 |
+
issues.append(issue)
|
| 550 |
+
return issues
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def check_text_density(slide_id: str, state: PipelineState) -> list[QualityIssue]:
|
| 554 |
+
slide = state.slides[slide_id]
|
| 555 |
+
role = slide.pedagogical_role.value
|
| 556 |
+
limits = TEXT_DENSITY_LIMITS.get(role, TEXT_DENSITY_LIMITS["unknown"])
|
| 557 |
+
issues: list[QualityIssue] = []
|
| 558 |
+
visible_words = _visible_words(_slide_visible_text(slide_id, state))
|
| 559 |
+
if visible_words > limits["max_visible_words"]:
|
| 560 |
+
issues.append(
|
| 561 |
+
upsert_issue(
|
| 562 |
+
state,
|
| 563 |
+
IssueType.TEXT_DENSITY_EXCEEDED,
|
| 564 |
+
IssueSeverity.MAJOR,
|
| 565 |
+
f"Slide {slide_id} has {visible_words} visible words; limit is "
|
| 566 |
+
f"{limits['max_visible_words']} for role {role}.",
|
| 567 |
+
stage_id="text_generation",
|
| 568 |
+
slide_id=slide_id,
|
| 569 |
+
suggested_fix="Reduce visible text or move explanation into speaker notes.",
|
| 570 |
+
)
|
| 571 |
+
)
|
| 572 |
+
if len(slide.bullet_points) > limits["max_bullets"]:
|
| 573 |
+
issues.append(
|
| 574 |
+
upsert_issue(
|
| 575 |
+
state,
|
| 576 |
+
IssueType.TEXT_DENSITY_EXCEEDED,
|
| 577 |
+
IssueSeverity.MAJOR,
|
| 578 |
+
f"Slide {slide_id} has {len(slide.bullet_points)} bullets; limit is "
|
| 579 |
+
f"{limits['max_bullets']} for role {role}.",
|
| 580 |
+
stage_id="text_generation",
|
| 581 |
+
slide_id=slide_id,
|
| 582 |
+
suggested_fix="Combine or remove bullets.",
|
| 583 |
+
)
|
| 584 |
+
)
|
| 585 |
+
concept_count = slide.distinct_concept_count if slide.distinct_concept_count is not None else 1
|
| 586 |
+
if len(slide.objective_ids) > 2 or concept_count > 3:
|
| 587 |
+
issues.append(
|
| 588 |
+
upsert_issue(
|
| 589 |
+
state,
|
| 590 |
+
IssueType.COGNITIVE_LOAD_HIGH,
|
| 591 |
+
IssueSeverity.MAJOR,
|
| 592 |
+
f"Slide {slide_id} carries too many objectives or concepts.",
|
| 593 |
+
stage_id="pedagogical_review",
|
| 594 |
+
slide_id=slide_id,
|
| 595 |
+
suggested_fix="Split the slide or narrow its instructional focus.",
|
| 596 |
+
)
|
| 597 |
+
)
|
| 598 |
+
return issues
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def check_speaker_notes(
|
| 602 |
+
slide_id: str,
|
| 603 |
+
state: PipelineState,
|
| 604 |
+
*,
|
| 605 |
+
strict: bool = False,
|
| 606 |
+
) -> list[QualityIssue]:
|
| 607 |
+
slide = state.slides[slide_id]
|
| 608 |
+
if slide.pedagogical_role.value not in INSTRUCTIONAL_ROLES:
|
| 609 |
+
return []
|
| 610 |
+
notes_text = slide.speaker_notes.notes_text if slide.speaker_notes else None
|
| 611 |
+
if notes_text and notes_text.strip():
|
| 612 |
+
return []
|
| 613 |
+
severity = IssueSeverity.BLOCKER if strict else IssueSeverity.MAJOR
|
| 614 |
+
stage_id = "pedagogical_review" if strict else "text_generation"
|
| 615 |
+
return [
|
| 616 |
+
upsert_issue(
|
| 617 |
+
state,
|
| 618 |
+
IssueType.SPEAKER_NOTES_MISSING,
|
| 619 |
+
severity,
|
| 620 |
+
f"Instructional slide {slide_id} is missing speaker notes.",
|
| 621 |
+
stage_id=stage_id,
|
| 622 |
+
slide_id=slide_id,
|
| 623 |
+
suggested_fix="Add instructor intent and teaching notes for this slide.",
|
| 624 |
+
)
|
| 625 |
+
]
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def _slot_text(value: Any) -> str:
|
| 629 |
+
if isinstance(value, list):
|
| 630 |
+
return " ".join(str(item) for item in value)
|
| 631 |
+
if isinstance(value, dict):
|
| 632 |
+
return json.dumps(value, sort_keys=True)
|
| 633 |
+
return str(value)
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def _has_raw_coordinates(layout: LayoutSpec) -> bool:
|
| 637 |
+
if RAW_COORDINATE_KEYS & set(layout.slot_assignments):
|
| 638 |
+
return True
|
| 639 |
+
for value in layout.slot_assignments.values():
|
| 640 |
+
if isinstance(value, dict) and RAW_COORDINATE_KEYS & set(value):
|
| 641 |
+
return True
|
| 642 |
+
return False
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def validate_layout_spec(layout: LayoutSpec, state: PipelineState) -> list[QualityIssue]:
|
| 646 |
+
issues: list[QualityIssue] = []
|
| 647 |
+
if _has_raw_coordinates(layout):
|
| 648 |
+
issues.append(
|
| 649 |
+
upsert_issue(
|
| 650 |
+
state,
|
| 651 |
+
IssueType.LAYOUT_SCHEMA_INVALID,
|
| 652 |
+
IssueSeverity.BLOCKER,
|
| 653 |
+
f"Slide {layout.slide_id} layout uses raw coordinates instead of template slots.",
|
| 654 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 655 |
+
slide_id=layout.slide_id,
|
| 656 |
+
suggested_fix="Use an approved layout_id and named template slots.",
|
| 657 |
+
)
|
| 658 |
+
)
|
| 659 |
+
return issues
|
| 660 |
+
|
| 661 |
+
registry_entry = APPROVED_LAYOUTS.get(layout.layout_id)
|
| 662 |
+
if registry_entry is None:
|
| 663 |
+
issues.append(
|
| 664 |
+
upsert_issue(
|
| 665 |
+
state,
|
| 666 |
+
IssueType.LAYOUT_SCHEMA_INVALID,
|
| 667 |
+
IssueSeverity.BLOCKER,
|
| 668 |
+
f"Slide {layout.slide_id} uses unknown layout_id {layout.layout_id}.",
|
| 669 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 670 |
+
slide_id=layout.slide_id,
|
| 671 |
+
suggested_fix="Choose an approved layout ID.",
|
| 672 |
+
)
|
| 673 |
+
)
|
| 674 |
+
return issues
|
| 675 |
+
|
| 676 |
+
required_slots = registry_entry["required_slots"]
|
| 677 |
+
optional_slots = registry_entry["optional_slots"]
|
| 678 |
+
slot_word_limits = registry_entry["slot_word_limits"]
|
| 679 |
+
used_slots = set(layout.slot_assignments)
|
| 680 |
+
missing = required_slots - used_slots # type: ignore[operator]
|
| 681 |
+
unknown = used_slots - required_slots - optional_slots # type: ignore[operator]
|
| 682 |
+
if missing:
|
| 683 |
+
issues.append(
|
| 684 |
+
upsert_issue(
|
| 685 |
+
state,
|
| 686 |
+
IssueType.LAYOUT_SLOT_VIOLATION,
|
| 687 |
+
IssueSeverity.BLOCKER,
|
| 688 |
+
f"Slide {layout.slide_id} is missing required layout slots: {sorted(missing)}.",
|
| 689 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 690 |
+
slide_id=layout.slide_id,
|
| 691 |
+
suggested_fix="Populate all required slots for the selected layout.",
|
| 692 |
+
)
|
| 693 |
+
)
|
| 694 |
+
if unknown:
|
| 695 |
+
issues.append(
|
| 696 |
+
upsert_issue(
|
| 697 |
+
state,
|
| 698 |
+
IssueType.LAYOUT_SLOT_VIOLATION,
|
| 699 |
+
IssueSeverity.MAJOR,
|
| 700 |
+
f"Slide {layout.slide_id} uses unknown slots: {sorted(unknown)}.",
|
| 701 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 702 |
+
slide_id=layout.slide_id,
|
| 703 |
+
suggested_fix="Remove arbitrary slots and use the template schema.",
|
| 704 |
+
)
|
| 705 |
+
)
|
| 706 |
+
for slot, limit in slot_word_limits.items(): # type: ignore[union-attr]
|
| 707 |
+
if slot not in layout.slot_assignments:
|
| 708 |
+
continue
|
| 709 |
+
word_count = _visible_words(_slot_text(layout.slot_assignments[slot]))
|
| 710 |
+
if word_count > limit:
|
| 711 |
+
issues.append(
|
| 712 |
+
upsert_issue(
|
| 713 |
+
state,
|
| 714 |
+
IssueType.LAYOUT_SLOT_VIOLATION,
|
| 715 |
+
IssueSeverity.MAJOR,
|
| 716 |
+
f"Slide {layout.slide_id} slot {slot} has {word_count} words; limit is {limit}.",
|
| 717 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 718 |
+
slide_id=layout.slide_id,
|
| 719 |
+
suggested_fix="Shorten text in this layout slot.",
|
| 720 |
+
)
|
| 721 |
+
)
|
| 722 |
+
return issues
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def check_accessibility(slide_id: str, state: PipelineState) -> list[QualityIssue]:
|
| 726 |
+
issues: list[QualityIssue] = []
|
| 727 |
+
slide = state.slides[slide_id]
|
| 728 |
+
visual_assets = [asset for asset in state.visual_assets.values() if asset.slide_id == slide_id]
|
| 729 |
+
for asset in visual_assets:
|
| 730 |
+
meaningful = asset.purpose in {"instructional", "unknown"}
|
| 731 |
+
if meaningful and not (asset.alt_text and asset.alt_text.strip()):
|
| 732 |
+
issues.append(
|
| 733 |
+
upsert_issue(
|
| 734 |
+
state,
|
| 735 |
+
IssueType.ALT_TEXT_MISSING,
|
| 736 |
+
IssueSeverity.MAJOR,
|
| 737 |
+
f"Visual asset {asset.asset_id} on slide {slide_id} is missing alt text.",
|
| 738 |
+
stage_id="aesthetic_review",
|
| 739 |
+
slide_id=slide_id,
|
| 740 |
+
suggested_fix="Add concise alt text for meaningful visuals.",
|
| 741 |
+
)
|
| 742 |
+
)
|
| 743 |
+
has_visible_text = bool(_slide_visible_text(slide_id, state).strip())
|
| 744 |
+
has_notes = bool(slide.speaker_notes and slide.speaker_notes.notes_text)
|
| 745 |
+
if visual_assets and not has_visible_text and not has_notes:
|
| 746 |
+
issues.append(
|
| 747 |
+
upsert_issue(
|
| 748 |
+
state,
|
| 749 |
+
IssueType.ALT_TEXT_MISSING,
|
| 750 |
+
IssueSeverity.MAJOR,
|
| 751 |
+
f"Image-only slide {slide_id} lacks accessible text or speaker notes.",
|
| 752 |
+
stage_id="aesthetic_review",
|
| 753 |
+
slide_id=slide_id,
|
| 754 |
+
suggested_fix="Add accessible slide text or speaker notes.",
|
| 755 |
+
)
|
| 756 |
+
)
|
| 757 |
+
contrast = slide.metadata.get("contrast_ratio")
|
| 758 |
+
if isinstance(contrast, int | float) and contrast < 4.5:
|
| 759 |
+
issues.append(
|
| 760 |
+
upsert_issue(
|
| 761 |
+
state,
|
| 762 |
+
IssueType.ACCESSIBILITY_CONTRAST_RISK,
|
| 763 |
+
IssueSeverity.MAJOR,
|
| 764 |
+
f"Slide {slide_id} has a contrast risk below 4.5:1.",
|
| 765 |
+
stage_id="aesthetic_review",
|
| 766 |
+
slide_id=slide_id,
|
| 767 |
+
suggested_fix="Adjust foreground/background colors in the template metadata.",
|
| 768 |
+
)
|
| 769 |
+
)
|
| 770 |
+
return issues
|
| 771 |
+
|
| 772 |
+
|
| 773 |
+
def check_visual_assets(slide_id: str, state: PipelineState) -> list[QualityIssue]:
|
| 774 |
+
slide = state.slides[slide_id]
|
| 775 |
+
assets = [asset for asset in state.visual_assets.values() if asset.slide_id == slide_id]
|
| 776 |
+
issues: list[QualityIssue] = []
|
| 777 |
+
if slide.requires_visual and not assets:
|
| 778 |
+
issues.append(
|
| 779 |
+
upsert_issue(
|
| 780 |
+
state,
|
| 781 |
+
IssueType.VISUAL_ASSET_MISSING,
|
| 782 |
+
IssueSeverity.BLOCKER,
|
| 783 |
+
f"Slide {slide_id} requires a visual asset but none exists.",
|
| 784 |
+
stage_id="image_visual_asset_generation",
|
| 785 |
+
slide_id=slide_id,
|
| 786 |
+
suggested_fix="Generate, upload, or assign a visual asset.",
|
| 787 |
+
)
|
| 788 |
+
)
|
| 789 |
+
for asset in assets:
|
| 790 |
+
if asset.purpose == "unknown":
|
| 791 |
+
issues.append(
|
| 792 |
+
upsert_issue(
|
| 793 |
+
state,
|
| 794 |
+
IssueType.VISUAL_ASSET_PURPOSE_MISSING,
|
| 795 |
+
IssueSeverity.MAJOR,
|
| 796 |
+
f"Visual asset {asset.asset_id} has no instructional/decorative purpose.",
|
| 797 |
+
stage_id="image_visual_asset_generation",
|
| 798 |
+
slide_id=slide_id,
|
| 799 |
+
suggested_fix="Mark the asset purpose before export.",
|
| 800 |
+
)
|
| 801 |
+
)
|
| 802 |
+
if asset.purpose in {"instructional", "unknown"} and not asset.alt_text:
|
| 803 |
+
issues.append(
|
| 804 |
+
upsert_issue(
|
| 805 |
+
state,
|
| 806 |
+
IssueType.ALT_TEXT_MISSING,
|
| 807 |
+
IssueSeverity.MAJOR,
|
| 808 |
+
f"Meaningful visual asset {asset.asset_id} is missing alt text.",
|
| 809 |
+
stage_id="image_visual_asset_generation",
|
| 810 |
+
slide_id=slide_id,
|
| 811 |
+
suggested_fix="Add alt text.",
|
| 812 |
+
)
|
| 813 |
+
)
|
| 814 |
+
if asset.license_status in {"unknown", "needs_review"}:
|
| 815 |
+
issues.append(
|
| 816 |
+
upsert_issue(
|
| 817 |
+
state,
|
| 818 |
+
IssueType.COPYRIGHT_OR_LICENSE_RISK,
|
| 819 |
+
IssueSeverity.MAJOR,
|
| 820 |
+
f"Visual asset {asset.asset_id} has unresolved license status.",
|
| 821 |
+
stage_id="image_visual_asset_generation",
|
| 822 |
+
slide_id=slide_id,
|
| 823 |
+
suggested_fix="Use generated/user-provided assets or review the license.",
|
| 824 |
+
)
|
| 825 |
+
)
|
| 826 |
+
return issues
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def compute_slide_status(slide_id: str, state: PipelineState) -> SlideQAStatus:
|
| 830 |
+
slide = state.slides[slide_id]
|
| 831 |
+
check_text_density(slide_id, state)
|
| 832 |
+
check_speaker_notes(slide_id, state)
|
| 833 |
+
check_visual_assets(slide_id, state)
|
| 834 |
+
check_accessibility(slide_id, state)
|
| 835 |
+
layout = state.layout_specs.get(slide_id)
|
| 836 |
+
if layout:
|
| 837 |
+
validate_layout_spec(layout, state)
|
| 838 |
+
|
| 839 |
+
issue_ids = [
|
| 840 |
+
issue.issue_id
|
| 841 |
+
for issue in unresolved_issues(state)
|
| 842 |
+
if issue.slide_id == slide_id
|
| 843 |
+
]
|
| 844 |
+
slide_issues = [state.issues[issue_id] for issue_id in issue_ids]
|
| 845 |
+
blocker_count = sum(issue.severity == IssueSeverity.BLOCKER for issue in slide_issues)
|
| 846 |
+
major_count = sum(issue.severity == IssueSeverity.MAJOR for issue in slide_issues)
|
| 847 |
+
aggregate_score = clamp_score(100 - blocker_count * 45 - major_count * 15)
|
| 848 |
+
technical_score = 0 if any(issue.issue_type == IssueType.UNSUPPORTED_CLAIM for issue in slide_issues) else 100
|
| 849 |
+
pedagogical_score = clamp_score(
|
| 850 |
+
100
|
| 851 |
+
- 20
|
| 852 |
+
* sum(
|
| 853 |
+
issue.issue_type
|
| 854 |
+
in {
|
| 855 |
+
IssueType.SPEAKER_NOTES_MISSING,
|
| 856 |
+
IssueType.COGNITIVE_LOAD_HIGH,
|
| 857 |
+
IssueType.TEXT_DENSITY_EXCEEDED,
|
| 858 |
+
}
|
| 859 |
+
for issue in slide_issues
|
| 860 |
+
)
|
| 861 |
+
)
|
| 862 |
+
aesthetic_score = clamp_score(
|
| 863 |
+
100
|
| 864 |
+
- 20
|
| 865 |
+
* sum(
|
| 866 |
+
issue.issue_type
|
| 867 |
+
in {
|
| 868 |
+
IssueType.LAYOUT_SCHEMA_INVALID,
|
| 869 |
+
IssueType.LAYOUT_SLOT_VIOLATION,
|
| 870 |
+
IssueType.ALT_TEXT_MISSING,
|
| 871 |
+
IssueType.ACCESSIBILITY_CONTRAST_RISK,
|
| 872 |
+
}
|
| 873 |
+
for issue in slide_issues
|
| 874 |
+
)
|
| 875 |
+
)
|
| 876 |
+
stale = any(state.stages[stage_id].is_stale for stage_id in SLIDE_REVIEW_STAGE_IDS)
|
| 877 |
+
if blocker_count:
|
| 878 |
+
status = "blocked"
|
| 879 |
+
elif stale:
|
| 880 |
+
status = "stale"
|
| 881 |
+
elif major_count or aggregate_score < PASSING_SCORE:
|
| 882 |
+
status = "needs_revision"
|
| 883 |
+
elif all(has_valid_human_approval(stage_id, state) for stage_id in ("technical_review", "pedagogical_review", "aesthetic_review")):
|
| 884 |
+
status = "approved"
|
| 885 |
+
else:
|
| 886 |
+
status = "needs_review"
|
| 887 |
+
|
| 888 |
+
qa_status = SlideQAStatus(
|
| 889 |
+
slide_id=slide_id,
|
| 890 |
+
slide_number=slide.slide_number,
|
| 891 |
+
title=slide.title,
|
| 892 |
+
pedagogical_role=slide.pedagogical_role,
|
| 893 |
+
objective_ids=slide.objective_ids,
|
| 894 |
+
claim_ids=[claim.claim_id for claim in state.claims.values() if claim.slide_id == slide_id],
|
| 895 |
+
visual_asset_ids=[asset.asset_id for asset in state.visual_assets.values() if asset.slide_id == slide_id],
|
| 896 |
+
layout_id=layout.layout_id if layout else None,
|
| 897 |
+
aggregate_score=aggregate_score,
|
| 898 |
+
technical_score=technical_score,
|
| 899 |
+
pedagogical_score=pedagogical_score,
|
| 900 |
+
aesthetic_score=aesthetic_score,
|
| 901 |
+
issue_ids=issue_ids,
|
| 902 |
+
stale=stale,
|
| 903 |
+
status=status, # type: ignore[arg-type]
|
| 904 |
+
)
|
| 905 |
+
state.slide_statuses[slide_id] = qa_status
|
| 906 |
+
record_audit(
|
| 907 |
+
state,
|
| 908 |
+
"slide_status_updated",
|
| 909 |
+
slide_id=slide_id,
|
| 910 |
+
metadata={"status": qa_status.status, "aggregate_score": qa_status.aggregate_score},
|
| 911 |
+
)
|
| 912 |
+
return qa_status
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def _coverage_average(state: PipelineState) -> int:
|
| 916 |
+
if not state.objective_traces:
|
| 917 |
+
compute_objective_traces(state)
|
| 918 |
+
if not state.objective_traces:
|
| 919 |
+
return 0
|
| 920 |
+
return clamp_score(
|
| 921 |
+
sum(trace.coverage_score for trace in state.objective_traces.values())
|
| 922 |
+
/ len(state.objective_traces)
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def _deduct_for_issue_types(
|
| 927 |
+
state: PipelineState,
|
| 928 |
+
issue_types: set[IssueType],
|
| 929 |
+
*,
|
| 930 |
+
stage_id: str | None = None,
|
| 931 |
+
) -> int:
|
| 932 |
+
issues = [
|
| 933 |
+
issue
|
| 934 |
+
for issue in unresolved_issues(state)
|
| 935 |
+
if issue.issue_type in issue_types and (stage_id is None or issue.stage_id == stage_id)
|
| 936 |
+
]
|
| 937 |
+
if any(issue.severity == IssueSeverity.BLOCKER for issue in issues):
|
| 938 |
+
return 0
|
| 939 |
+
penalty = sum(45 if issue.severity == IssueSeverity.BLOCKER else 15 for issue in issues)
|
| 940 |
+
return clamp_score(100 - penalty)
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
def _score_dimension(stage_id: str, dimension_id: str, state: PipelineState) -> tuple[int, str]:
|
| 944 |
+
if stage_id == "setup_inputs":
|
| 945 |
+
required = [state.deck_title, state.source_url, state.template_url]
|
| 946 |
+
if dimension_id == "required_inputs_present":
|
| 947 |
+
return (100 if all(required) else 30, "Deck title, source URL, and template URL are checked.")
|
| 948 |
+
if dimension_id == "url_validity":
|
| 949 |
+
urls = [state.source_url or "", state.template_url or ""]
|
| 950 |
+
valid = all(url.startswith(("http://", "https://", "mock://")) for url in urls)
|
| 951 |
+
return (100 if valid else 40, "Source and template URLs must be explicit URLs.")
|
| 952 |
+
safe = state.dry_run or bool(state.output_folder_id)
|
| 953 |
+
if state.mutation_target_url and state.mutation_target_url in {state.source_url, state.template_url}:
|
| 954 |
+
safe = False
|
| 955 |
+
return (100 if safe else 0, "Render/export targets must be safe.")
|
| 956 |
+
|
| 957 |
+
if stage_id == "source_extraction_objective_mapping":
|
| 958 |
+
if dimension_id == "source_extraction_completeness":
|
| 959 |
+
return (100 if state.source_chunks else 40, "At least one source chunk is required.")
|
| 960 |
+
if dimension_id == "objective_clarity":
|
| 961 |
+
clear = state.objectives and all(_visible_words(text) >= 3 for text in state.objectives.values())
|
| 962 |
+
return (100 if clear else 45, "Objectives should be readable, specific statements.")
|
| 963 |
+
objective_texts = [text.strip().lower() for text in state.objectives.values()]
|
| 964 |
+
unique = len(objective_texts) == len(set(objective_texts))
|
| 965 |
+
return (100 if unique else 55, "Objectives should not duplicate one another.")
|
| 966 |
+
|
| 967 |
+
if stage_id == "slide_outline_order":
|
| 968 |
+
compute_objective_traces(state)
|
| 969 |
+
if dimension_id == "objective_coverage":
|
| 970 |
+
return (_coverage_average(state), "Coverage is aggregated from objective traces.")
|
| 971 |
+
if dimension_id == "logical_flow":
|
| 972 |
+
ordered = [slide.slide_number for slide in state.slides.values()]
|
| 973 |
+
roles_known = all(slide.pedagogical_role.value != "unknown" for slide in state.slides.values())
|
| 974 |
+
return (100 if ordered == sorted(ordered) and roles_known else 65, "Slide order and roles are checked.")
|
| 975 |
+
if dimension_id == "appropriate_slide_count":
|
| 976 |
+
upper = max(1, len(state.objectives) * 3 + 2)
|
| 977 |
+
ok = 1 <= len(state.slides) <= upper
|
| 978 |
+
return (100 if ok else 60, "Slide count should fit the number of objectives.")
|
| 979 |
+
titles = [slide.title for slide in state.slides.values() if slide.title]
|
| 980 |
+
unique = len(titles) == len(set(titles))
|
| 981 |
+
return (100 if unique else 65, "Slide titles are used as a redundancy signal.")
|
| 982 |
+
|
| 983 |
+
if stage_id == "title_generation":
|
| 984 |
+
titled = [slide for slide in state.slides.values() if slide.title and len(slide.title.split()) >= 2]
|
| 985 |
+
if dimension_id == "title_clarity":
|
| 986 |
+
return (100 if len(titled) == len(state.slides) and state.slides else 45, "Every slide needs a clear title.")
|
| 987 |
+
if dimension_id == "title_specificity":
|
| 988 |
+
specific = all(slide.title and _visible_words(slide.title) >= 2 for slide in state.slides.values())
|
| 989 |
+
return (100 if specific else 60, "Titles should be more specific than a section label.")
|
| 990 |
+
aligned = all(slide.objective_ids for slide in state.slides.values())
|
| 991 |
+
return (100 if aligned else 60, "Titles are expected on objective-mapped slides.")
|
| 992 |
+
|
| 993 |
+
if stage_id == "text_generation":
|
| 994 |
+
for slide_id in state.slides:
|
| 995 |
+
check_text_density(slide_id, state)
|
| 996 |
+
check_speaker_notes(slide_id, state)
|
| 997 |
+
validate_claim_support(state)
|
| 998 |
+
if dimension_id == "clarity":
|
| 999 |
+
populated = all(_slide_visible_text(slide_id, state) for slide_id in state.slides)
|
| 1000 |
+
return (100 if populated else 45, "Slides should contain visible instructional text.")
|
| 1001 |
+
if dimension_id == "text_density":
|
| 1002 |
+
return (
|
| 1003 |
+
_deduct_for_issue_types(
|
| 1004 |
+
state,
|
| 1005 |
+
{IssueType.TEXT_DENSITY_EXCEEDED, IssueType.COGNITIVE_LOAD_HIGH},
|
| 1006 |
+
),
|
| 1007 |
+
"Density and cognitive-load checks are deterministic.",
|
| 1008 |
+
)
|
| 1009 |
+
if dimension_id == "source_grounding":
|
| 1010 |
+
return (_deduct_for_issue_types(state, {IssueType.UNSUPPORTED_CLAIM}), "Claims must be source grounded.")
|
| 1011 |
+
return (
|
| 1012 |
+
_deduct_for_issue_types(state, {IssueType.SPEAKER_NOTES_MISSING}),
|
| 1013 |
+
"Instructional roles should have speaker notes.",
|
| 1014 |
+
)
|
| 1015 |
+
|
| 1016 |
+
if stage_id == "image_visual_asset_generation":
|
| 1017 |
+
for slide_id in state.slides:
|
| 1018 |
+
check_visual_assets(slide_id, state)
|
| 1019 |
+
if dimension_id == "asset_completeness":
|
| 1020 |
+
return (_deduct_for_issue_types(state, {IssueType.VISUAL_ASSET_MISSING}), "Required visuals must exist.")
|
| 1021 |
+
if dimension_id == "alt_text_presence":
|
| 1022 |
+
return (_deduct_for_issue_types(state, {IssueType.ALT_TEXT_MISSING}), "Meaningful visuals need alt text.")
|
| 1023 |
+
if dimension_id == "license_or_generation_metadata":
|
| 1024 |
+
return (
|
| 1025 |
+
_deduct_for_issue_types(state, {IssueType.COPYRIGHT_OR_LICENSE_RISK}),
|
| 1026 |
+
"Visual sources and license metadata are checked.",
|
| 1027 |
+
)
|
| 1028 |
+
return (
|
| 1029 |
+
_deduct_for_issue_types(state, {IssueType.VISUAL_ASSET_PURPOSE_MISSING}),
|
| 1030 |
+
"Visual purpose metadata is checked.",
|
| 1031 |
+
)
|
| 1032 |
+
|
| 1033 |
+
if stage_id == "aesthetic_ordering_visual_composition":
|
| 1034 |
+
for layout in state.layout_specs.values():
|
| 1035 |
+
validate_layout_spec(layout, state)
|
| 1036 |
+
if dimension_id == "layout_schema_validity":
|
| 1037 |
+
return (_deduct_for_issue_types(state, {IssueType.LAYOUT_SCHEMA_INVALID}), "Layout IDs must be approved.")
|
| 1038 |
+
if dimension_id == "slot_compliance":
|
| 1039 |
+
return (_deduct_for_issue_types(state, {IssueType.LAYOUT_SLOT_VIOLATION}), "Slots must fit layout schema.")
|
| 1040 |
+
if dimension_id == "visual_hierarchy":
|
| 1041 |
+
return (100 if state.layout_specs else 50, "Slides should have explicit layouts.")
|
| 1042 |
+
return (_deduct_for_issue_types(state, {IssueType.TEXT_DENSITY_EXCEEDED}), "Readability uses density signals.")
|
| 1043 |
+
|
| 1044 |
+
if stage_id == "technical_review":
|
| 1045 |
+
validate_claim_support(state)
|
| 1046 |
+
if dimension_id == "unsupported_claim_detection":
|
| 1047 |
+
return (_deduct_for_issue_types(state, {IssueType.UNSUPPORTED_CLAIM}), "Unsupported claims are blockers.")
|
| 1048 |
+
if dimension_id == "technical_accuracy":
|
| 1049 |
+
return (_deduct_for_issue_types(state, {IssueType.TECHNICAL_INACCURACY}), "Technical issues are tracked.")
|
| 1050 |
+
return (100 if state.claims else 85, "Claims provide terminology review surface.")
|
| 1051 |
+
|
| 1052 |
+
if stage_id == "pedagogical_review":
|
| 1053 |
+
compute_objective_traces(state)
|
| 1054 |
+
for slide_id in state.slides:
|
| 1055 |
+
check_text_density(slide_id, state)
|
| 1056 |
+
check_speaker_notes(slide_id, state, strict=True)
|
| 1057 |
+
if dimension_id == "objective_coverage":
|
| 1058 |
+
return (_coverage_average(state), "Objective coverage is aggregated from traces.")
|
| 1059 |
+
if dimension_id == "speaker_notes_teachability":
|
| 1060 |
+
return (_deduct_for_issue_types(state, {IssueType.SPEAKER_NOTES_MISSING}), "Speaker notes are required.")
|
| 1061 |
+
if dimension_id == "examples_or_applications":
|
| 1062 |
+
has_example = any(
|
| 1063 |
+
slide.pedagogical_role.value in {"worked_example", "knowledge_check"}
|
| 1064 |
+
for slide in state.slides.values()
|
| 1065 |
+
)
|
| 1066 |
+
return (100 if has_example else 75, "At least one practice/application role is preferred.")
|
| 1067 |
+
return (100 if state.slides else 0, "Slides should progress through an ordered sequence.")
|
| 1068 |
+
|
| 1069 |
+
if stage_id == "aesthetic_review":
|
| 1070 |
+
for slide_id in state.slides:
|
| 1071 |
+
check_accessibility(slide_id, state)
|
| 1072 |
+
for layout in state.layout_specs.values():
|
| 1073 |
+
validate_layout_spec(layout, state)
|
| 1074 |
+
if dimension_id == "accessibility":
|
| 1075 |
+
return (
|
| 1076 |
+
_deduct_for_issue_types(
|
| 1077 |
+
state,
|
| 1078 |
+
{IssueType.ALT_TEXT_MISSING, IssueType.ACCESSIBILITY_CONTRAST_RISK},
|
| 1079 |
+
),
|
| 1080 |
+
"P0 accessibility checks alt text and contrast metadata only.",
|
| 1081 |
+
)
|
| 1082 |
+
if dimension_id == "brand_template_compliance":
|
| 1083 |
+
compliant = all(layout.approved_template_id for layout in state.layout_specs.values())
|
| 1084 |
+
return (100 if compliant and state.layout_specs else 65, "Layouts should bind to an approved template.")
|
| 1085 |
+
if dimension_id == "layout_consistency":
|
| 1086 |
+
known = all(layout.layout_id in APPROVED_LAYOUTS for layout in state.layout_specs.values())
|
| 1087 |
+
return (100 if known and state.layout_specs else 45, "Layouts must be from the approved registry.")
|
| 1088 |
+
return (_deduct_for_issue_types(state, {IssueType.TEXT_DENSITY_EXCEEDED}), "Readability uses density signals.")
|
| 1089 |
+
|
| 1090 |
+
if stage_id == "final_render_export":
|
| 1091 |
+
report = run_export_preflight(state)
|
| 1092 |
+
if dimension_id == "preflight_passed":
|
| 1093 |
+
return (100 if report.can_export else 0, report.summary)
|
| 1094 |
+
if dimension_id == "all_required_approvals_valid":
|
| 1095 |
+
valid_count = sum(has_valid_human_approval(stage_id, state) for stage_id in APPROVAL_REQUIRED_STAGE_IDS)
|
| 1096 |
+
return (
|
| 1097 |
+
clamp_score(100 * valid_count / len(APPROVAL_REQUIRED_STAGE_IDS)),
|
| 1098 |
+
"All stages 1-10 require valid approvals.",
|
| 1099 |
+
)
|
| 1100 |
+
if dimension_id == "no_stale_stages":
|
| 1101 |
+
stale_count = sum(stage.is_stale for stage in state.stages.values())
|
| 1102 |
+
return (100 if stale_count == 0 else 0, "Stale stages block export.")
|
| 1103 |
+
unsafe = state.mutation_target_url and state.mutation_target_url in {state.source_url, state.template_url}
|
| 1104 |
+
return (0 if unsafe else 100, "Export target safety is checked.")
|
| 1105 |
+
|
| 1106 |
+
if stage_id == "audit_log_version_history":
|
| 1107 |
+
if dimension_id == "audit_events_present":
|
| 1108 |
+
return (100 if state.audit_events else 50, "Audit events should exist.")
|
| 1109 |
+
if dimension_id == "artifact_versions_traceable":
|
| 1110 |
+
traceable = all(artifact.content_hash for artifact in state.artifacts.values())
|
| 1111 |
+
return (100 if traceable and state.artifacts else 50, "Artifact hashes provide traceability.")
|
| 1112 |
+
return (100 if state.reviewer_notes else 80, "Reviewer notes are tracked when present.")
|
| 1113 |
+
|
| 1114 |
+
return (80, "Default deterministic rubric score.")
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
def grade_stage(stage_id: str, state: PipelineState) -> StageGradeResult:
|
| 1118 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 1119 |
+
rubric_scores: list[RubricDimensionScore] = []
|
| 1120 |
+
for dimension_id, label, weight in STAGE_RUBRICS[stage_id]:
|
| 1121 |
+
dimension_score, rationale = _score_dimension(stage_id, dimension_id, state)
|
| 1122 |
+
rubric_scores.append(
|
| 1123 |
+
RubricDimensionScore(
|
| 1124 |
+
dimension_id=dimension_id,
|
| 1125 |
+
label=label,
|
| 1126 |
+
score=dimension_score,
|
| 1127 |
+
weight=weight,
|
| 1128 |
+
rationale=rationale,
|
| 1129 |
+
)
|
| 1130 |
+
)
|
| 1131 |
+
record_audit(
|
| 1132 |
+
state,
|
| 1133 |
+
"rubric_score_created",
|
| 1134 |
+
stage_id=stage_id,
|
| 1135 |
+
artifact_version_id=artifact.artifact_version_id if artifact else None,
|
| 1136 |
+
metadata={"dimension_id": dimension_id, "score": dimension_score},
|
| 1137 |
+
)
|
| 1138 |
+
score = aggregate_rubric_score(rubric_scores)
|
| 1139 |
+
issue_ids = [issue.issue_id for issue in unresolved_stage_issues(stage_id, state)]
|
| 1140 |
+
recommended_actions = [
|
| 1141 |
+
issue.suggested_fix or issue.message
|
| 1142 |
+
for issue in unresolved_stage_issues(stage_id, state)
|
| 1143 |
+
if issue.severity in {IssueSeverity.MAJOR, IssueSeverity.BLOCKER}
|
| 1144 |
+
]
|
| 1145 |
+
result = StageGradeResult(
|
| 1146 |
+
stage_id=stage_id,
|
| 1147 |
+
artifact_version_id=artifact.artifact_version_id if artifact else None,
|
| 1148 |
+
score=score,
|
| 1149 |
+
passed_threshold=score >= PASSING_SCORE,
|
| 1150 |
+
rubric_scores=rubric_scores,
|
| 1151 |
+
issue_ids=issue_ids,
|
| 1152 |
+
recommended_actions=list(dict.fromkeys(recommended_actions)),
|
| 1153 |
+
graded_at=now_iso(),
|
| 1154 |
+
)
|
| 1155 |
+
state.stages[stage_id].score = score
|
| 1156 |
+
state.stages[stage_id].grade_result = result
|
| 1157 |
+
record_audit(
|
| 1158 |
+
state,
|
| 1159 |
+
"stage_grade_created",
|
| 1160 |
+
stage_id=stage_id,
|
| 1161 |
+
artifact_version_id=result.artifact_version_id,
|
| 1162 |
+
metadata={"score": score, "passed_threshold": result.passed_threshold},
|
| 1163 |
+
)
|
| 1164 |
+
return result
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
def compute_deck_health_summary(state: PipelineState) -> DeckHealthSummary:
|
| 1168 |
+
if state.objectives:
|
| 1169 |
+
compute_objective_traces(state)
|
| 1170 |
+
for slide_id in state.slides:
|
| 1171 |
+
compute_slide_status(slide_id, state)
|
| 1172 |
+
unresolved = unresolved_issues(state)
|
| 1173 |
+
blockers = [issue for issue in unresolved if issue.severity == IssueSeverity.BLOCKER]
|
| 1174 |
+
majors = [issue for issue in unresolved if issue.severity == IssueSeverity.MAJOR]
|
| 1175 |
+
slide_scores = [
|
| 1176 |
+
status.aggregate_score
|
| 1177 |
+
for status in state.slide_statuses.values()
|
| 1178 |
+
if status.aggregate_score is not None
|
| 1179 |
+
]
|
| 1180 |
+
invalidated_approvals = [
|
| 1181 |
+
approval for approval in state.approvals if approval.approval_status == "invalidated"
|
| 1182 |
+
]
|
| 1183 |
+
unsupported_claims = [
|
| 1184 |
+
issue for issue in unresolved if issue.issue_type == IssueType.UNSUPPORTED_CLAIM
|
| 1185 |
+
]
|
| 1186 |
+
objectives_strong = sum(
|
| 1187 |
+
trace.coverage_status == "strong" for trace in state.objective_traces.values()
|
| 1188 |
+
)
|
| 1189 |
+
objectives_partial_or_weak = sum(
|
| 1190 |
+
trace.coverage_status in {"partial", "weak"}
|
| 1191 |
+
for trace in state.objective_traces.values()
|
| 1192 |
+
)
|
| 1193 |
+
objectives_uncovered = sum(
|
| 1194 |
+
trace.coverage_status == "uncovered" for trace in state.objective_traces.values()
|
| 1195 |
+
)
|
| 1196 |
+
can_export_now = (
|
| 1197 |
+
not blockers
|
| 1198 |
+
and not invalidated_approvals
|
| 1199 |
+
and not any(stage.is_stale for stage in state.stages.values())
|
| 1200 |
+
and all(has_valid_human_approval(stage_id, state) for stage_id in APPROVAL_REQUIRED_STAGE_IDS)
|
| 1201 |
+
)
|
| 1202 |
+
return DeckHealthSummary(
|
| 1203 |
+
job_id=state.job_id,
|
| 1204 |
+
deck_title=state.deck_title,
|
| 1205 |
+
approved_stage_count=sum(
|
| 1206 |
+
has_valid_human_approval(stage_id, state) for stage_id in state.stages
|
| 1207 |
+
),
|
| 1208 |
+
total_stage_count=len(state.stages),
|
| 1209 |
+
stale_stage_count=sum(stage.is_stale for stage in state.stages.values()),
|
| 1210 |
+
invalidated_approval_count=len(invalidated_approvals),
|
| 1211 |
+
unresolved_blocker_count=len(blockers),
|
| 1212 |
+
unresolved_major_issue_count=len(majors),
|
| 1213 |
+
slide_count=len(state.slides),
|
| 1214 |
+
average_slide_score=(
|
| 1215 |
+
clamp_score(sum(slide_scores) / len(slide_scores)) if slide_scores else None
|
| 1216 |
+
),
|
| 1217 |
+
objectives_total=len(state.objectives),
|
| 1218 |
+
objectives_strong=objectives_strong,
|
| 1219 |
+
objectives_partial_or_weak=objectives_partial_or_weak,
|
| 1220 |
+
objectives_uncovered=objectives_uncovered,
|
| 1221 |
+
unsupported_claim_count=len(unsupported_claims),
|
| 1222 |
+
can_export=can_export_now,
|
| 1223 |
+
top_blockers=[issue.message for issue in blockers[:5]],
|
| 1224 |
+
)
|
| 1225 |
+
|
| 1226 |
+
|
| 1227 |
+
def compute_artifact_diff(previous_content: str | dict, current_content: str | dict) -> str:
|
| 1228 |
+
if isinstance(previous_content, dict):
|
| 1229 |
+
previous = json.dumps(previous_content, sort_keys=True, indent=2).splitlines(keepends=True)
|
| 1230 |
+
else:
|
| 1231 |
+
previous = str(previous_content).splitlines(keepends=True)
|
| 1232 |
+
if isinstance(current_content, dict):
|
| 1233 |
+
current = json.dumps(current_content, sort_keys=True, indent=2).splitlines(keepends=True)
|
| 1234 |
+
else:
|
| 1235 |
+
current = str(current_content).splitlines(keepends=True)
|
| 1236 |
+
diff = difflib.unified_diff(previous, current, fromfile="previous", tofile="current")
|
| 1237 |
+
return "".join(diff)
|
| 1238 |
+
|
| 1239 |
+
|
| 1240 |
+
def _preflight_blocker(
|
| 1241 |
+
state: PipelineState,
|
| 1242 |
+
message: str,
|
| 1243 |
+
*,
|
| 1244 |
+
stage_id: str = "final_render_export",
|
| 1245 |
+
slide_id: str | None = None,
|
| 1246 |
+
artifact_version_id: str | None = None,
|
| 1247 |
+
suggested_fix: str | None = None,
|
| 1248 |
+
) -> QualityIssue:
|
| 1249 |
+
return upsert_issue(
|
| 1250 |
+
state,
|
| 1251 |
+
IssueType.EXPORT_PREFLIGHT_FAILED,
|
| 1252 |
+
IssueSeverity.BLOCKER,
|
| 1253 |
+
message,
|
| 1254 |
+
stage_id=stage_id,
|
| 1255 |
+
slide_id=slide_id,
|
| 1256 |
+
artifact_version_id=artifact_version_id,
|
| 1257 |
+
suggested_fix=suggested_fix,
|
| 1258 |
+
)
|
| 1259 |
+
|
| 1260 |
+
|
| 1261 |
+
def _check_preflight_prerequisites(state: PipelineState) -> None:
|
| 1262 |
+
if not state.deck_title or not state.source_url or not state.template_url:
|
| 1263 |
+
upsert_issue(
|
| 1264 |
+
state,
|
| 1265 |
+
IssueType.MISSING_REQUIRED_INPUT,
|
| 1266 |
+
IssueSeverity.BLOCKER,
|
| 1267 |
+
"Deck title, source URL, and template URL are required.",
|
| 1268 |
+
stage_id="setup_inputs",
|
| 1269 |
+
suggested_fix="Complete setup inputs before export.",
|
| 1270 |
+
)
|
| 1271 |
+
for stage_id in APPROVAL_REQUIRED_STAGE_IDS:
|
| 1272 |
+
stage = state.stages[stage_id]
|
| 1273 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 1274 |
+
if stage.score is None or stage.score < PASSING_SCORE:
|
| 1275 |
+
upsert_issue(
|
| 1276 |
+
state,
|
| 1277 |
+
IssueType.SCORE_BELOW_THRESHOLD,
|
| 1278 |
+
IssueSeverity.BLOCKER,
|
| 1279 |
+
f"{stage_id} must be scored at 80 or higher before export.",
|
| 1280 |
+
stage_id=stage_id,
|
| 1281 |
+
suggested_fix="Grade or improve this stage.",
|
| 1282 |
+
)
|
| 1283 |
+
if not has_valid_human_approval(stage_id, state):
|
| 1284 |
+
upsert_issue(
|
| 1285 |
+
state,
|
| 1286 |
+
IssueType.HUMAN_APPROVAL_MISSING,
|
| 1287 |
+
IssueSeverity.BLOCKER,
|
| 1288 |
+
f"{stage_id} does not have valid approval for its current artifact.",
|
| 1289 |
+
stage_id=stage_id,
|
| 1290 |
+
artifact_version_id=artifact.artifact_version_id if artifact else None,
|
| 1291 |
+
suggested_fix="Approve the current passing artifact.",
|
| 1292 |
+
)
|
| 1293 |
+
if artifact is None:
|
| 1294 |
+
_preflight_blocker(
|
| 1295 |
+
state,
|
| 1296 |
+
f"{stage_id} has no current artifact.",
|
| 1297 |
+
stage_id=stage_id,
|
| 1298 |
+
suggested_fix="Generate this stage.",
|
| 1299 |
+
)
|
| 1300 |
+
elif artifact.status not in {ArtifactStatus.APPROVED, ArtifactStatus.EXPORTED}:
|
| 1301 |
+
_preflight_blocker(
|
| 1302 |
+
state,
|
| 1303 |
+
f"{stage_id} current artifact is {artifact.status.value}; export requires approved artifacts.",
|
| 1304 |
+
stage_id=stage_id,
|
| 1305 |
+
artifact_version_id=artifact.artifact_version_id,
|
| 1306 |
+
suggested_fix="Approve the current artifact.",
|
| 1307 |
+
)
|
| 1308 |
+
for stage_id, stage in state.stages.items():
|
| 1309 |
+
if stage.is_stale:
|
| 1310 |
+
upsert_issue(
|
| 1311 |
+
state,
|
| 1312 |
+
IssueType.STALE_DOWNSTREAM_STAGE,
|
| 1313 |
+
IssueSeverity.BLOCKER,
|
| 1314 |
+
f"{stage_id} is stale and cannot be exported.",
|
| 1315 |
+
stage_id=stage_id,
|
| 1316 |
+
suggested_fix="Regenerate or re-grade stale stages.",
|
| 1317 |
+
)
|
| 1318 |
+
|
| 1319 |
+
|
| 1320 |
+
def _check_preflight_slide_requirements(state: PipelineState) -> None:
|
| 1321 |
+
if not state.slides:
|
| 1322 |
+
_preflight_blocker(
|
| 1323 |
+
state,
|
| 1324 |
+
"Slide inventory is missing.",
|
| 1325 |
+
suggested_fix="Generate a slide outline before export.",
|
| 1326 |
+
)
|
| 1327 |
+
return
|
| 1328 |
+
for slide_id, slide in state.slides.items():
|
| 1329 |
+
if not slide.title:
|
| 1330 |
+
_preflight_blocker(
|
| 1331 |
+
state,
|
| 1332 |
+
f"Slide {slide_id} is missing a title.",
|
| 1333 |
+
slide_id=slide_id,
|
| 1334 |
+
suggested_fix="Generate or edit slide titles.",
|
| 1335 |
+
)
|
| 1336 |
+
if slide.pedagogical_role.value == "unknown":
|
| 1337 |
+
_preflight_blocker(
|
| 1338 |
+
state,
|
| 1339 |
+
f"Slide {slide_id} is missing a pedagogical role.",
|
| 1340 |
+
slide_id=slide_id,
|
| 1341 |
+
suggested_fix="Assign a pedagogical role.",
|
| 1342 |
+
)
|
| 1343 |
+
check_speaker_notes(slide_id, state, strict=True)
|
| 1344 |
+
check_text_density(slide_id, state)
|
| 1345 |
+
check_visual_assets(slide_id, state)
|
| 1346 |
+
check_accessibility(slide_id, state)
|
| 1347 |
+
if slide_id not in state.layout_specs:
|
| 1348 |
+
upsert_issue(
|
| 1349 |
+
state,
|
| 1350 |
+
IssueType.LAYOUT_SCHEMA_INVALID,
|
| 1351 |
+
IssueSeverity.BLOCKER,
|
| 1352 |
+
f"Slide {slide_id} is missing layout JSON.",
|
| 1353 |
+
stage_id="aesthetic_ordering_visual_composition",
|
| 1354 |
+
slide_id=slide_id,
|
| 1355 |
+
suggested_fix="Assign an approved template-first layout.",
|
| 1356 |
+
)
|
| 1357 |
+
else:
|
| 1358 |
+
validate_layout_spec(state.layout_specs[slide_id], state)
|
| 1359 |
+
status = compute_slide_status(slide_id, state)
|
| 1360 |
+
if status.aggregate_score is None or status.aggregate_score < PASSING_SCORE:
|
| 1361 |
+
_preflight_blocker(
|
| 1362 |
+
state,
|
| 1363 |
+
f"Slide {slide_id} aggregate score is below 80.",
|
| 1364 |
+
slide_id=slide_id,
|
| 1365 |
+
suggested_fix="Resolve slide-level issues.",
|
| 1366 |
+
)
|
| 1367 |
+
|
| 1368 |
+
|
| 1369 |
+
def _check_preflight_render_safety(state: PipelineState) -> None:
|
| 1370 |
+
if not state.dry_run and not state.output_folder_id:
|
| 1371 |
+
upsert_issue(
|
| 1372 |
+
state,
|
| 1373 |
+
IssueType.MISSING_REQUIRED_INPUT,
|
| 1374 |
+
IssueSeverity.BLOCKER,
|
| 1375 |
+
"Output folder ID is required when dry-run mode is off.",
|
| 1376 |
+
stage_id="setup_inputs",
|
| 1377 |
+
suggested_fix="Provide an output folder ID or keep dry-run mode enabled.",
|
| 1378 |
+
)
|
| 1379 |
+
if state.production_export_requested and state.dry_run:
|
| 1380 |
+
upsert_issue(
|
| 1381 |
+
state,
|
| 1382 |
+
IssueType.RENDER_SAFETY_VIOLATION,
|
| 1383 |
+
IssueSeverity.BLOCKER,
|
| 1384 |
+
"Dry-run mode cannot perform a production export.",
|
| 1385 |
+
stage_id="final_render_export",
|
| 1386 |
+
suggested_fix="Disable dry-run mode only after all export targets are safe.",
|
| 1387 |
+
)
|
| 1388 |
+
if state.mutation_target_url and state.mutation_target_url in {state.source_url, state.template_url}:
|
| 1389 |
+
upsert_issue(
|
| 1390 |
+
state,
|
| 1391 |
+
IssueType.RENDER_SAFETY_VIOLATION,
|
| 1392 |
+
IssueSeverity.BLOCKER,
|
| 1393 |
+
"Source/template URLs cannot be used as mutation targets.",
|
| 1394 |
+
stage_id="final_render_export",
|
| 1395 |
+
suggested_fix="Use a separate output folder or generated deck target.",
|
| 1396 |
+
)
|
| 1397 |
+
|
| 1398 |
+
|
| 1399 |
+
def run_export_preflight(state: PipelineState) -> ExportPreflightReport:
|
| 1400 |
+
record_audit(state, "preflight_run", stage_id="final_render_export")
|
| 1401 |
+
_check_preflight_prerequisites(state)
|
| 1402 |
+
if state.objectives:
|
| 1403 |
+
compute_objective_traces(state)
|
| 1404 |
+
else:
|
| 1405 |
+
upsert_issue(
|
| 1406 |
+
state,
|
| 1407 |
+
IssueType.OBJECTIVE_UNCOVERED,
|
| 1408 |
+
IssueSeverity.BLOCKER,
|
| 1409 |
+
"No learning objectives exist.",
|
| 1410 |
+
stage_id="source_extraction_objective_mapping",
|
| 1411 |
+
suggested_fix="Extract or provide learning objectives.",
|
| 1412 |
+
)
|
| 1413 |
+
validate_claim_support(state)
|
| 1414 |
+
_check_preflight_slide_requirements(state)
|
| 1415 |
+
_check_preflight_render_safety(state)
|
| 1416 |
+
|
| 1417 |
+
blocking_issue_ids = [
|
| 1418 |
+
issue.issue_id
|
| 1419 |
+
for issue in unresolved_issues(state)
|
| 1420 |
+
if issue.severity == IssueSeverity.BLOCKER
|
| 1421 |
+
]
|
| 1422 |
+
warning_issue_ids = [
|
| 1423 |
+
issue.issue_id
|
| 1424 |
+
for issue in unresolved_issues(state)
|
| 1425 |
+
if issue.severity in {IssueSeverity.MAJOR, IssueSeverity.MINOR}
|
| 1426 |
+
]
|
| 1427 |
+
can_export = not blocking_issue_ids
|
| 1428 |
+
summary = (
|
| 1429 |
+
"Export preflight passed."
|
| 1430 |
+
if can_export
|
| 1431 |
+
else f"Export preflight failed with {len(blocking_issue_ids)} blocker(s)."
|
| 1432 |
+
)
|
| 1433 |
+
report = ExportPreflightReport(
|
| 1434 |
+
can_export=can_export,
|
| 1435 |
+
checked_at=now_iso(),
|
| 1436 |
+
blocking_issue_ids=blocking_issue_ids,
|
| 1437 |
+
warning_issue_ids=warning_issue_ids,
|
| 1438 |
+
summary=summary,
|
| 1439 |
+
)
|
| 1440 |
+
state.export_preflight_report = report
|
| 1441 |
+
record_audit(
|
| 1442 |
+
state,
|
| 1443 |
+
"preflight_passed" if can_export else "preflight_failed",
|
| 1444 |
+
stage_id="final_render_export",
|
| 1445 |
+
metadata={"blocking_issue_count": len(blocking_issue_ids)},
|
| 1446 |
+
)
|
| 1447 |
+
return report
|
| 1448 |
+
|
| 1449 |
+
|
| 1450 |
+
def can_export(state: PipelineState) -> bool:
|
| 1451 |
+
return run_export_preflight(state).can_export
|
course_slide_factory/workflow.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from .constants import STAGE_IDS, STAGE_LABELS, STAGE_SEQUENCE
|
| 7 |
+
from .compat import model_to_dict, model_validate
|
| 8 |
+
from .models import (
|
| 9 |
+
ArtifactStatus,
|
| 10 |
+
LayoutSpec,
|
| 11 |
+
PedagogicalRole,
|
| 12 |
+
PipelineState,
|
| 13 |
+
PromptRun,
|
| 14 |
+
RequestedChange,
|
| 15 |
+
ReviewerNotes,
|
| 16 |
+
Slide,
|
| 17 |
+
SlideClaim,
|
| 18 |
+
SpeakerNotes,
|
| 19 |
+
StageState,
|
| 20 |
+
VisualAsset,
|
| 21 |
+
)
|
| 22 |
+
from .quality import (
|
| 23 |
+
approve_current_artifact,
|
| 24 |
+
can_unlock_next_stage,
|
| 25 |
+
compute_artifact_diff,
|
| 26 |
+
compute_deck_health_summary,
|
| 27 |
+
compute_objective_traces,
|
| 28 |
+
compute_slide_status,
|
| 29 |
+
create_artifact_version,
|
| 30 |
+
get_current_stage_artifact,
|
| 31 |
+
get_stage_lock_reasons,
|
| 32 |
+
grade_stage,
|
| 33 |
+
has_unresolved_blockers,
|
| 34 |
+
now_iso,
|
| 35 |
+
record_audit,
|
| 36 |
+
run_export_preflight,
|
| 37 |
+
stable_hash,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_empty_state(
|
| 42 |
+
*,
|
| 43 |
+
deck_title: str | None = None,
|
| 44 |
+
source_url: str | None = None,
|
| 45 |
+
template_url: str | None = None,
|
| 46 |
+
output_folder_id: str | None = None,
|
| 47 |
+
dry_run: bool = True,
|
| 48 |
+
mutation_target_url: str | None = None,
|
| 49 |
+
production_export_requested: bool = False,
|
| 50 |
+
) -> PipelineState:
|
| 51 |
+
job_seed = "|".join([deck_title or "course-deck", source_url or "mock://source"])
|
| 52 |
+
state = PipelineState(
|
| 53 |
+
job_id=f"job_{stable_hash(job_seed)[:10]}",
|
| 54 |
+
deck_title=deck_title,
|
| 55 |
+
source_url=source_url,
|
| 56 |
+
template_url=template_url,
|
| 57 |
+
output_folder_id=output_folder_id,
|
| 58 |
+
dry_run=dry_run,
|
| 59 |
+
mutation_target_url=mutation_target_url,
|
| 60 |
+
production_export_requested=production_export_requested,
|
| 61 |
+
stages={
|
| 62 |
+
stage_id: StageState(stage_id=stage_id, label=label)
|
| 63 |
+
for stage_id, label in STAGE_SEQUENCE
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
+
record_audit(state, "state_initialized", metadata={"dry_run": dry_run})
|
| 67 |
+
return state
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def state_to_dict(state: PipelineState) -> dict[str, Any]:
|
| 71 |
+
return model_to_dict(state)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def state_from_dict(data: dict[str, Any] | PipelineState | None) -> PipelineState:
|
| 75 |
+
if isinstance(data, PipelineState):
|
| 76 |
+
return data
|
| 77 |
+
if not data:
|
| 78 |
+
return build_empty_state()
|
| 79 |
+
return model_validate(PipelineState, data)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def parse_objective_lines(objectives_text: str | None) -> dict[str, str]:
|
| 83 |
+
objectives: dict[str, str] = {}
|
| 84 |
+
for index, raw_line in enumerate((objectives_text or "").splitlines(), start=1):
|
| 85 |
+
line = raw_line.strip(" -\t")
|
| 86 |
+
if not line:
|
| 87 |
+
continue
|
| 88 |
+
if ":" in line and line.split(":", 1)[0].strip().lower().startswith("obj"):
|
| 89 |
+
objective_id, objective = line.split(":", 1)
|
| 90 |
+
objectives[objective_id.strip()] = objective.strip()
|
| 91 |
+
else:
|
| 92 |
+
objectives[f"obj_{index}"] = line
|
| 93 |
+
return objectives
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def update_setup_from_inputs(
|
| 97 |
+
state: PipelineState,
|
| 98 |
+
*,
|
| 99 |
+
deck_title: str | None,
|
| 100 |
+
source_url: str | None,
|
| 101 |
+
template_url: str | None,
|
| 102 |
+
output_folder_id: str | None,
|
| 103 |
+
dry_run: bool,
|
| 104 |
+
objectives_text: str | None,
|
| 105 |
+
source_text: str | None,
|
| 106 |
+
mutation_target_url: str | None = None,
|
| 107 |
+
production_export_requested: bool = False,
|
| 108 |
+
) -> PipelineState:
|
| 109 |
+
state.deck_title = deck_title
|
| 110 |
+
state.source_url = source_url
|
| 111 |
+
state.template_url = template_url
|
| 112 |
+
state.output_folder_id = output_folder_id
|
| 113 |
+
state.dry_run = dry_run
|
| 114 |
+
state.mutation_target_url = mutation_target_url
|
| 115 |
+
state.production_export_requested = production_export_requested
|
| 116 |
+
parsed_objectives = parse_objective_lines(objectives_text)
|
| 117 |
+
if parsed_objectives:
|
| 118 |
+
state.objectives = parsed_objectives
|
| 119 |
+
if source_text and source_text.strip():
|
| 120 |
+
state.source_chunks = {"chunk_1": source_text.strip()}
|
| 121 |
+
return state
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def record_prompt_run(
|
| 125 |
+
state: PipelineState,
|
| 126 |
+
stage_id: str,
|
| 127 |
+
*,
|
| 128 |
+
rendered_prompt: str,
|
| 129 |
+
provider: str = "mock",
|
| 130 |
+
input_artifact_version_ids: list[str] | None = None,
|
| 131 |
+
) -> PromptRun:
|
| 132 |
+
prompt_run_id = f"prompt_{len(state.prompt_runs) + 1:05d}"
|
| 133 |
+
run = PromptRun(
|
| 134 |
+
prompt_run_id=prompt_run_id,
|
| 135 |
+
stage_id=stage_id,
|
| 136 |
+
provider=provider, # type: ignore[arg-type]
|
| 137 |
+
model_name="mock-deterministic-v1" if provider == "mock" else None,
|
| 138 |
+
prompt_template_id=f"{stage_id}_template",
|
| 139 |
+
prompt_template_version="p0.1",
|
| 140 |
+
rendered_prompt=rendered_prompt,
|
| 141 |
+
input_artifact_version_ids=input_artifact_version_ids or [],
|
| 142 |
+
settings={"temperature": 0, "dry_run": state.dry_run},
|
| 143 |
+
created_at=now_iso(),
|
| 144 |
+
)
|
| 145 |
+
state.prompt_runs[prompt_run_id] = run
|
| 146 |
+
record_audit(
|
| 147 |
+
state,
|
| 148 |
+
"prompt_run_recorded",
|
| 149 |
+
stage_id=stage_id,
|
| 150 |
+
metadata={"prompt_run_id": prompt_run_id, "provider": provider},
|
| 151 |
+
)
|
| 152 |
+
return run
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _ensure_default_objectives_and_source(state: PipelineState) -> None:
|
| 156 |
+
if not state.source_chunks:
|
| 157 |
+
state.source_chunks = {
|
| 158 |
+
"chunk_1": "Mock source chunk describing the key course ideas and examples."
|
| 159 |
+
}
|
| 160 |
+
if not state.objectives:
|
| 161 |
+
state.objectives = {
|
| 162 |
+
"obj_1": "Explain the core concept in plain language.",
|
| 163 |
+
"obj_2": "Apply the concept to a worked example.",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _ensure_default_slides(state: PipelineState) -> None:
|
| 168 |
+
_ensure_default_objectives_and_source(state)
|
| 169 |
+
if state.slides:
|
| 170 |
+
return
|
| 171 |
+
slides: dict[str, Slide] = {}
|
| 172 |
+
for index, objective_id in enumerate(state.objectives, start=1):
|
| 173 |
+
role = PedagogicalRole.CONCEPT if index == 1 else PedagogicalRole.WORKED_EXAMPLE
|
| 174 |
+
if index == len(state.objectives) and len(state.objectives) > 2:
|
| 175 |
+
role = PedagogicalRole.SUMMARY
|
| 176 |
+
slide_id = f"slide_{index}"
|
| 177 |
+
slides[slide_id] = Slide(
|
| 178 |
+
slide_id=slide_id,
|
| 179 |
+
slide_number=index,
|
| 180 |
+
title=f"{state.objectives[objective_id].rstrip('.')}",
|
| 181 |
+
visible_text=f"This slide teaches {state.objectives[objective_id].lower()}",
|
| 182 |
+
bullet_points=["Key idea", "Example", "Takeaway"] if role != PedagogicalRole.CONCEPT else ["Key idea"],
|
| 183 |
+
objective_ids=[objective_id],
|
| 184 |
+
pedagogical_role=role,
|
| 185 |
+
requires_visual=index == 1,
|
| 186 |
+
speaker_notes=SpeakerNotes(
|
| 187 |
+
slide_id=slide_id,
|
| 188 |
+
notes_text=f"Guide learners through objective {objective_id} with one concrete example.",
|
| 189 |
+
instructor_intent="Keep the explanation concrete and source-grounded.",
|
| 190 |
+
estimated_teaching_time_seconds=180,
|
| 191 |
+
possible_student_confusions=["Students may overgeneralize the example."],
|
| 192 |
+
teaching_tips=["Ask learners to restate the idea before moving on."],
|
| 193 |
+
),
|
| 194 |
+
)
|
| 195 |
+
state.slides = slides
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _ensure_claims(state: PipelineState) -> None:
|
| 199 |
+
if state.claims:
|
| 200 |
+
return
|
| 201 |
+
for slide in state.slides.values():
|
| 202 |
+
claim_id = f"claim_{slide.slide_number}"
|
| 203 |
+
state.claims[claim_id] = SlideClaim(
|
| 204 |
+
claim_id=claim_id,
|
| 205 |
+
slide_id=slide.slide_id,
|
| 206 |
+
claim_text=slide.visible_text or slide.title or "Instructional claim",
|
| 207 |
+
source_ids=["source_1"],
|
| 208 |
+
source_chunk_ids=list(state.source_chunks)[:1],
|
| 209 |
+
review_status="supported",
|
| 210 |
+
)
|
| 211 |
+
record_audit(
|
| 212 |
+
state,
|
| 213 |
+
"claim_created",
|
| 214 |
+
slide_id=slide.slide_id,
|
| 215 |
+
claim_id=claim_id,
|
| 216 |
+
metadata={"review_status": "supported"},
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _ensure_visual_assets(state: PipelineState) -> None:
|
| 221 |
+
for slide in state.slides.values():
|
| 222 |
+
if not slide.requires_visual:
|
| 223 |
+
continue
|
| 224 |
+
if any(asset.slide_id == slide.slide_id for asset in state.visual_assets.values()):
|
| 225 |
+
continue
|
| 226 |
+
asset_id = f"asset_{slide.slide_number}"
|
| 227 |
+
state.visual_assets[asset_id] = VisualAsset(
|
| 228 |
+
asset_id=asset_id,
|
| 229 |
+
slide_id=slide.slide_id,
|
| 230 |
+
asset_type="diagram",
|
| 231 |
+
path_or_url=f"mock://assets/{asset_id}",
|
| 232 |
+
prompt=f"Instructional diagram for {slide.title}",
|
| 233 |
+
purpose="instructional",
|
| 234 |
+
alt_text=f"Diagram illustrating {slide.title}",
|
| 235 |
+
source="mock",
|
| 236 |
+
license_status="generated",
|
| 237 |
+
approved_for_export=True,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _ensure_layouts(state: PipelineState) -> None:
|
| 242 |
+
for slide in state.slides.values():
|
| 243 |
+
if slide.slide_id in state.layout_specs:
|
| 244 |
+
continue
|
| 245 |
+
if slide.requires_visual:
|
| 246 |
+
layout_id = "title_bullets_visual"
|
| 247 |
+
slots = {
|
| 248 |
+
"title": slide.title or "",
|
| 249 |
+
"bullets": slide.bullet_points,
|
| 250 |
+
"visual": "primary_visual",
|
| 251 |
+
}
|
| 252 |
+
elif slide.pedagogical_role == PedagogicalRole.WORKED_EXAMPLE:
|
| 253 |
+
layout_id = "worked_example"
|
| 254 |
+
slots = {
|
| 255 |
+
"title": slide.title or "",
|
| 256 |
+
"problem": slide.visible_text,
|
| 257 |
+
"steps": slide.bullet_points,
|
| 258 |
+
}
|
| 259 |
+
else:
|
| 260 |
+
layout_id = "title_body"
|
| 261 |
+
slots = {"title": slide.title or "", "body": slide.visible_text}
|
| 262 |
+
state.layout_specs[slide.slide_id] = LayoutSpec(
|
| 263 |
+
slide_id=slide.slide_id,
|
| 264 |
+
layout_id=layout_id,
|
| 265 |
+
approved_template_id="default_course_template",
|
| 266 |
+
slot_assignments=slots,
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def generate_stage(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
|
| 271 |
+
if stage_id not in STAGE_IDS:
|
| 272 |
+
raise ValueError(f"Unknown stage: {stage_id}")
|
| 273 |
+
input_ids = [
|
| 274 |
+
artifact.artifact_version_id
|
| 275 |
+
for artifact in state.artifacts.values()
|
| 276 |
+
if artifact.stage_id in STAGE_IDS[: STAGE_IDS.index(stage_id)]
|
| 277 |
+
and artifact.is_current
|
| 278 |
+
]
|
| 279 |
+
prompt_run = record_prompt_run(
|
| 280 |
+
state,
|
| 281 |
+
stage_id,
|
| 282 |
+
rendered_prompt=f"Mock deterministic generation for {STAGE_LABELS[stage_id]}",
|
| 283 |
+
input_artifact_version_ids=input_ids,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
if stage_id == "setup_inputs":
|
| 287 |
+
content = {
|
| 288 |
+
"deck_title": state.deck_title,
|
| 289 |
+
"source_url": state.source_url,
|
| 290 |
+
"template_url": state.template_url,
|
| 291 |
+
"dry_run": state.dry_run,
|
| 292 |
+
"output_folder_id": state.output_folder_id,
|
| 293 |
+
}
|
| 294 |
+
elif stage_id == "source_extraction_objective_mapping":
|
| 295 |
+
_ensure_default_objectives_and_source(state)
|
| 296 |
+
content = {"source_chunks": state.source_chunks, "objectives": state.objectives}
|
| 297 |
+
elif stage_id == "slide_outline_order":
|
| 298 |
+
_ensure_default_slides(state)
|
| 299 |
+
compute_objective_traces(state)
|
| 300 |
+
content = {"slides": [model_to_dict(slide) for slide in state.slides.values()]}
|
| 301 |
+
elif stage_id == "title_generation":
|
| 302 |
+
_ensure_default_slides(state)
|
| 303 |
+
for slide in state.slides.values():
|
| 304 |
+
slide.title = slide.title or f"Slide {slide.slide_number}"
|
| 305 |
+
content = {"titles": {slide.slide_id: slide.title for slide in state.slides.values()}}
|
| 306 |
+
elif stage_id == "text_generation":
|
| 307 |
+
_ensure_default_slides(state)
|
| 308 |
+
_ensure_claims(state)
|
| 309 |
+
content = {
|
| 310 |
+
"slides": [model_to_dict(slide) for slide in state.slides.values()],
|
| 311 |
+
"claims": [model_to_dict(claim) for claim in state.claims.values()],
|
| 312 |
+
}
|
| 313 |
+
elif stage_id == "image_visual_asset_generation":
|
| 314 |
+
_ensure_default_slides(state)
|
| 315 |
+
_ensure_visual_assets(state)
|
| 316 |
+
content = {"visual_assets": [model_to_dict(asset) for asset in state.visual_assets.values()]}
|
| 317 |
+
elif stage_id == "aesthetic_ordering_visual_composition":
|
| 318 |
+
_ensure_default_slides(state)
|
| 319 |
+
_ensure_layouts(state)
|
| 320 |
+
content = {"layout_specs": [model_to_dict(layout) for layout in state.layout_specs.values()]}
|
| 321 |
+
elif stage_id in {"technical_review", "pedagogical_review", "aesthetic_review"}:
|
| 322 |
+
content = {
|
| 323 |
+
"review_stage": stage_id,
|
| 324 |
+
"checked_at": now_iso(),
|
| 325 |
+
"slide_count": len(state.slides),
|
| 326 |
+
}
|
| 327 |
+
elif stage_id == "final_render_export":
|
| 328 |
+
report = run_export_preflight(state)
|
| 329 |
+
content = {"preflight": model_to_dict(report)}
|
| 330 |
+
else:
|
| 331 |
+
content = {
|
| 332 |
+
"audit_event_count": len(state.audit_events),
|
| 333 |
+
"artifact_count": len(state.artifacts),
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
artifact = create_artifact_version(
|
| 337 |
+
state,
|
| 338 |
+
stage_id,
|
| 339 |
+
content,
|
| 340 |
+
created_by="mock",
|
| 341 |
+
status=ArtifactStatus.CANDIDATE,
|
| 342 |
+
prompt_run_id=prompt_run.prompt_run_id,
|
| 343 |
+
)
|
| 344 |
+
prompt_run.output_artifact_version_id = artifact.artifact_version_id
|
| 345 |
+
prompt_run.artifact_version_id = artifact.artifact_version_id
|
| 346 |
+
return state, f"Generated {STAGE_LABELS[stage_id]} as {artifact.artifact_version_id}."
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def grade_stage_action(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
|
| 350 |
+
result = grade_stage(stage_id, state)
|
| 351 |
+
for slide_id in state.slides:
|
| 352 |
+
compute_slide_status(slide_id, state)
|
| 353 |
+
compute_deck_health_summary(state)
|
| 354 |
+
return state, f"Grade for {STAGE_LABELS[stage_id]}: {result.score}."
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def save_human_edits(
|
| 358 |
+
state: PipelineState,
|
| 359 |
+
stage_id: str,
|
| 360 |
+
edited_content: str,
|
| 361 |
+
reviewer_name: str,
|
| 362 |
+
reviewer_summary: str,
|
| 363 |
+
requested_changes_json: str,
|
| 364 |
+
) -> tuple[PipelineState, str]:
|
| 365 |
+
requested_changes: list[RequestedChange] = []
|
| 366 |
+
if requested_changes_json.strip():
|
| 367 |
+
parsed = json.loads(requested_changes_json)
|
| 368 |
+
if not isinstance(parsed, list):
|
| 369 |
+
raise ValueError("Requested changes JSON must be a list.")
|
| 370 |
+
requested_changes = [model_validate(RequestedChange, item) for item in parsed]
|
| 371 |
+
current = get_current_stage_artifact(stage_id, state)
|
| 372 |
+
notes = ReviewerNotes(
|
| 373 |
+
stage_id=stage_id,
|
| 374 |
+
artifact_version_id=current.artifact_version_id if current else None,
|
| 375 |
+
reviewer_name=reviewer_name or "human_reviewer",
|
| 376 |
+
summary=reviewer_summary or None,
|
| 377 |
+
requested_changes=requested_changes,
|
| 378 |
+
created_at=now_iso(),
|
| 379 |
+
)
|
| 380 |
+
state.reviewer_notes.append(notes)
|
| 381 |
+
record_audit(
|
| 382 |
+
state,
|
| 383 |
+
"reviewer_notes_saved",
|
| 384 |
+
stage_id=stage_id,
|
| 385 |
+
artifact_version_id=notes.artifact_version_id,
|
| 386 |
+
metadata={"requested_change_count": len(requested_changes)},
|
| 387 |
+
)
|
| 388 |
+
artifact = create_artifact_version(
|
| 389 |
+
state,
|
| 390 |
+
stage_id,
|
| 391 |
+
{"human_edit": edited_content, "reviewer_notes": model_to_dict(notes)},
|
| 392 |
+
created_by="human",
|
| 393 |
+
status=ArtifactStatus.CANDIDATE,
|
| 394 |
+
)
|
| 395 |
+
return state, f"Saved human edits as candidate {artifact.artifact_version_id}."
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def improve_with_ai(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
|
| 399 |
+
current = get_current_stage_artifact(stage_id, state)
|
| 400 |
+
input_ids = [current.artifact_version_id] if current else []
|
| 401 |
+
prompt_run = record_prompt_run(
|
| 402 |
+
state,
|
| 403 |
+
stage_id,
|
| 404 |
+
rendered_prompt=f"Mock AI improvement for {STAGE_LABELS[stage_id]}",
|
| 405 |
+
input_artifact_version_ids=input_ids,
|
| 406 |
+
)
|
| 407 |
+
current_content = current.metadata.get("content") if current else {}
|
| 408 |
+
content = {
|
| 409 |
+
"ai_improved_from": current.artifact_version_id if current else None,
|
| 410 |
+
"content": current_content,
|
| 411 |
+
"improvement_note": "Mock AI pass reduced ambiguity and preserved traceability.",
|
| 412 |
+
}
|
| 413 |
+
artifact = create_artifact_version(
|
| 414 |
+
state,
|
| 415 |
+
stage_id,
|
| 416 |
+
content,
|
| 417 |
+
created_by="ai",
|
| 418 |
+
status=ArtifactStatus.CANDIDATE,
|
| 419 |
+
prompt_run_id=prompt_run.prompt_run_id,
|
| 420 |
+
)
|
| 421 |
+
prompt_run.output_artifact_version_id = artifact.artifact_version_id
|
| 422 |
+
prompt_run.artifact_version_id = artifact.artifact_version_id
|
| 423 |
+
return state, f"Created AI-improved candidate {artifact.artifact_version_id}."
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def approve_and_continue(
|
| 427 |
+
state: PipelineState,
|
| 428 |
+
stage_id: str,
|
| 429 |
+
reviewer_name: str = "human_reviewer",
|
| 430 |
+
) -> tuple[PipelineState, str]:
|
| 431 |
+
stage = state.stages[stage_id]
|
| 432 |
+
if stage.score is None or stage.score < 80:
|
| 433 |
+
return state, "Approval blocked: stage score is below 80 or missing."
|
| 434 |
+
if has_unresolved_blockers(stage_id, state):
|
| 435 |
+
return state, "Approval blocked: stage has unresolved blockers."
|
| 436 |
+
if stage.is_stale:
|
| 437 |
+
return state, "Approval blocked: stage is stale."
|
| 438 |
+
if get_current_stage_artifact(stage_id, state) is None:
|
| 439 |
+
return state, "Approval blocked: current artifact is missing."
|
| 440 |
+
approval = approve_current_artifact(state, stage_id, reviewer_name)
|
| 441 |
+
unlocks = can_unlock_next_stage(stage_id, state)
|
| 442 |
+
message = (
|
| 443 |
+
f"Approved {approval.artifact_version_id}; next stage can unlock."
|
| 444 |
+
if unlocks
|
| 445 |
+
else f"Approved {approval.artifact_version_id}; next stage remains locked."
|
| 446 |
+
)
|
| 447 |
+
return state, message
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def run_preflight_action(state: PipelineState) -> tuple[PipelineState, str]:
|
| 451 |
+
report = run_export_preflight(state)
|
| 452 |
+
return state, report.summary
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def final_render_export(state: PipelineState) -> tuple[PipelineState, str]:
|
| 456 |
+
report = run_export_preflight(state)
|
| 457 |
+
if not report.can_export:
|
| 458 |
+
record_audit(
|
| 459 |
+
state,
|
| 460 |
+
"export_blocked_by_preflight",
|
| 461 |
+
stage_id="final_render_export",
|
| 462 |
+
metadata={"blocking_issue_ids": report.blocking_issue_ids},
|
| 463 |
+
)
|
| 464 |
+
return state, report.summary
|
| 465 |
+
for stage_id in STAGE_IDS[:11]:
|
| 466 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 467 |
+
if artifact and artifact.status == ArtifactStatus.APPROVED:
|
| 468 |
+
artifact.status = ArtifactStatus.EXPORTED
|
| 469 |
+
record_audit(
|
| 470 |
+
state,
|
| 471 |
+
"export_completed",
|
| 472 |
+
stage_id="final_render_export",
|
| 473 |
+
metadata={"dry_run": state.dry_run},
|
| 474 |
+
)
|
| 475 |
+
return state, "Dry-run export completed." if state.dry_run else "Production export completed."
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def current_artifact_text(state: PipelineState, stage_id: str) -> str:
|
| 479 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 480 |
+
if artifact is None:
|
| 481 |
+
return ""
|
| 482 |
+
return json.dumps(artifact.metadata.get("content", {}), indent=2, sort_keys=True)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def current_artifact_diff(state: PipelineState, stage_id: str) -> str:
|
| 486 |
+
versions = state.stage_artifact_versions.get(stage_id, [])
|
| 487 |
+
if len(versions) < 2:
|
| 488 |
+
return ""
|
| 489 |
+
previous = state.artifacts[versions[-2]].metadata.get("content", {})
|
| 490 |
+
current = state.artifacts[versions[-1]].metadata.get("content", {})
|
| 491 |
+
record_audit(state, "artifact_diff_viewed", stage_id=stage_id)
|
| 492 |
+
return compute_artifact_diff(previous, current)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def deck_health_table(state: PipelineState) -> list[list[Any]]:
|
| 496 |
+
summary = compute_deck_health_summary(state)
|
| 497 |
+
return [
|
| 498 |
+
["Job ID", summary.job_id],
|
| 499 |
+
["Deck title", summary.deck_title or ""],
|
| 500 |
+
["Approved stages", f"{summary.approved_stage_count}/{summary.total_stage_count}"],
|
| 501 |
+
["Stale stages", summary.stale_stage_count],
|
| 502 |
+
["Invalidated approvals", summary.invalidated_approval_count],
|
| 503 |
+
["Unresolved blockers", summary.unresolved_blocker_count],
|
| 504 |
+
["Unresolved major issues", summary.unresolved_major_issue_count],
|
| 505 |
+
["Slide count", summary.slide_count],
|
| 506 |
+
["Average slide score", summary.average_slide_score],
|
| 507 |
+
["Objectives strong", summary.objectives_strong],
|
| 508 |
+
["Objectives partial/weak", summary.objectives_partial_or_weak],
|
| 509 |
+
["Objectives uncovered", summary.objectives_uncovered],
|
| 510 |
+
["Unsupported claims", summary.unsupported_claim_count],
|
| 511 |
+
["Can export", summary.can_export],
|
| 512 |
+
["Top blockers", "; ".join(summary.top_blockers)],
|
| 513 |
+
]
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def slide_inventory_table(state: PipelineState) -> list[list[Any]]:
|
| 517 |
+
for slide_id in state.slides:
|
| 518 |
+
compute_slide_status(slide_id, state)
|
| 519 |
+
rows: list[list[Any]] = []
|
| 520 |
+
for status in sorted(state.slide_statuses.values(), key=lambda item: item.slide_number):
|
| 521 |
+
rows.append(
|
| 522 |
+
[
|
| 523 |
+
status.slide_number,
|
| 524 |
+
status.title or "",
|
| 525 |
+
status.pedagogical_role.value,
|
| 526 |
+
", ".join(status.objective_ids),
|
| 527 |
+
", ".join(status.claim_ids),
|
| 528 |
+
", ".join(status.visual_asset_ids),
|
| 529 |
+
status.aggregate_score,
|
| 530 |
+
status.technical_score,
|
| 531 |
+
status.pedagogical_score,
|
| 532 |
+
status.aesthetic_score,
|
| 533 |
+
status.status,
|
| 534 |
+
len(status.issue_ids),
|
| 535 |
+
status.stale,
|
| 536 |
+
]
|
| 537 |
+
)
|
| 538 |
+
return rows
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
def objective_matrix_table(state: PipelineState) -> list[list[Any]]:
|
| 542 |
+
compute_objective_traces(state)
|
| 543 |
+
return [
|
| 544 |
+
[
|
| 545 |
+
trace.objective_id,
|
| 546 |
+
trace.objective_text,
|
| 547 |
+
", ".join(trace.mapped_slide_ids),
|
| 548 |
+
trace.coverage_score,
|
| 549 |
+
trace.coverage_status,
|
| 550 |
+
len(trace.evidence),
|
| 551 |
+
", ".join(trace.issue_ids),
|
| 552 |
+
]
|
| 553 |
+
for trace in state.objective_traces.values()
|
| 554 |
+
]
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def stage_status_table(state: PipelineState) -> list[list[Any]]:
|
| 558 |
+
rows: list[list[Any]] = []
|
| 559 |
+
for stage_id in STAGE_IDS:
|
| 560 |
+
stage = state.stages[stage_id]
|
| 561 |
+
artifact = get_current_stage_artifact(stage_id, state)
|
| 562 |
+
rows.append(
|
| 563 |
+
[
|
| 564 |
+
STAGE_IDS.index(stage_id) + 1,
|
| 565 |
+
stage.label,
|
| 566 |
+
stage.score,
|
| 567 |
+
artifact.artifact_version_id if artifact else "",
|
| 568 |
+
artifact.status.value if artifact else "",
|
| 569 |
+
stage.is_stale,
|
| 570 |
+
can_unlock_next_stage(stage_id, state),
|
| 571 |
+
"; ".join(get_stage_lock_reasons(stage_id, state)),
|
| 572 |
+
]
|
| 573 |
+
)
|
| 574 |
+
return rows
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def issue_table(state: PipelineState) -> list[list[Any]]:
|
| 578 |
+
return [
|
| 579 |
+
[
|
| 580 |
+
issue.issue_id,
|
| 581 |
+
issue.issue_type.value,
|
| 582 |
+
issue.severity.value,
|
| 583 |
+
issue.stage_id or "",
|
| 584 |
+
issue.slide_id or "",
|
| 585 |
+
issue.message,
|
| 586 |
+
issue.resolved,
|
| 587 |
+
]
|
| 588 |
+
for issue in state.issues.values()
|
| 589 |
+
]
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def preflight_table(state: PipelineState) -> list[list[Any]]:
|
| 593 |
+
report = state.export_preflight_report
|
| 594 |
+
if report is None:
|
| 595 |
+
return []
|
| 596 |
+
return [
|
| 597 |
+
["can_export", report.can_export],
|
| 598 |
+
["checked_at", report.checked_at],
|
| 599 |
+
["blocking_issue_ids", "\n".join(report.blocking_issue_ids)],
|
| 600 |
+
["warning_issue_ids", "\n".join(report.warning_issue_ids)],
|
| 601 |
+
["summary", report.summary],
|
| 602 |
+
]
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def audit_table(state: PipelineState) -> list[list[Any]]:
|
| 606 |
+
return [
|
| 607 |
+
[
|
| 608 |
+
event.event_id,
|
| 609 |
+
event.event_type,
|
| 610 |
+
event.timestamp,
|
| 611 |
+
event.stage_id or "",
|
| 612 |
+
event.slide_id or "",
|
| 613 |
+
event.issue_id or "",
|
| 614 |
+
event.reason or "",
|
| 615 |
+
]
|
| 616 |
+
for event in state.audit_events[-50:]
|
| 617 |
+
]
|
pyproject.toml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool.pytest.ini_options]
|
| 2 |
+
pythonpath = ["."]
|
| 3 |
+
testpaths = ["tests"]
|
| 4 |
+
addopts = ["-p", "no:cacheprovider"]
|
| 5 |
+
|
| 6 |
+
[tool.ruff]
|
| 7 |
+
line-length = 140
|
| 8 |
+
target-version = "py311"
|
| 9 |
+
cache-dir = "/tmp/slide_creation_engine_ruff_cache"
|
| 10 |
+
|
| 11 |
+
[tool.ruff.lint]
|
| 12 |
+
select = ["E", "F", "B"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.19.0
|
| 2 |
+
pydantic>=2.7
|
| 3 |
+
pytest>=8.0
|
| 4 |
+
ruff>=0.8
|
tests/test_p0_quality.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from course_slide_factory.constants import STAGE_IDS
|
| 4 |
+
from course_slide_factory.fixtures import (
|
| 5 |
+
invalid_layout_job,
|
| 6 |
+
invalidated_approval_job,
|
| 7 |
+
missing_objective_mapping_job,
|
| 8 |
+
missing_visual_asset_job,
|
| 9 |
+
stale_downstream_job,
|
| 10 |
+
unsupported_claim_job,
|
| 11 |
+
valid_minimal_job,
|
| 12 |
+
)
|
| 13 |
+
from course_slide_factory.models import ArtifactStatus, IssueSeverity, IssueType, LayoutSpec
|
| 14 |
+
from course_slide_factory.quality import (
|
| 15 |
+
aggregate_rubric_score,
|
| 16 |
+
approve_current_artifact,
|
| 17 |
+
can_unlock_next_stage,
|
| 18 |
+
check_text_density,
|
| 19 |
+
compute_artifact_diff,
|
| 20 |
+
compute_objective_traces,
|
| 21 |
+
get_current_stage_artifact,
|
| 22 |
+
get_stage_lock_reasons,
|
| 23 |
+
grade_stage,
|
| 24 |
+
has_valid_human_approval,
|
| 25 |
+
run_export_preflight,
|
| 26 |
+
upsert_issue,
|
| 27 |
+
validate_claim_support,
|
| 28 |
+
validate_layout_spec,
|
| 29 |
+
)
|
| 30 |
+
from course_slide_factory.workflow import (
|
| 31 |
+
build_empty_state,
|
| 32 |
+
final_render_export,
|
| 33 |
+
generate_stage,
|
| 34 |
+
improve_with_ai,
|
| 35 |
+
save_human_edits,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_weighted_rubric_score_aggregates_and_clamps():
|
| 40 |
+
from course_slide_factory.models import RubricDimensionScore
|
| 41 |
+
|
| 42 |
+
score = aggregate_rubric_score(
|
| 43 |
+
[
|
| 44 |
+
RubricDimensionScore(dimension_id="a", label="A", score=100, weight=3),
|
| 45 |
+
RubricDimensionScore(dimension_id="b", label="B", score=50, weight=1),
|
| 46 |
+
]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
assert score == 88
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_gating_requires_score_no_blockers_approval_and_current_artifact():
|
| 53 |
+
state = valid_minimal_job()
|
| 54 |
+
stage_id = "setup_inputs"
|
| 55 |
+
|
| 56 |
+
assert can_unlock_next_stage(stage_id, state)
|
| 57 |
+
|
| 58 |
+
state.stages[stage_id].score = 79
|
| 59 |
+
assert not can_unlock_next_stage(stage_id, state)
|
| 60 |
+
|
| 61 |
+
state = valid_minimal_job()
|
| 62 |
+
upsert_issue(
|
| 63 |
+
state,
|
| 64 |
+
IssueType.TECHNICAL_INACCURACY,
|
| 65 |
+
IssueSeverity.BLOCKER,
|
| 66 |
+
"Blocking issue",
|
| 67 |
+
stage_id=stage_id,
|
| 68 |
+
)
|
| 69 |
+
assert not can_unlock_next_stage(stage_id, state)
|
| 70 |
+
|
| 71 |
+
state = valid_minimal_job()
|
| 72 |
+
state.approvals = [approval for approval in state.approvals if approval.stage_id != stage_id]
|
| 73 |
+
assert not can_unlock_next_stage(stage_id, state)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_approval_invalidates_after_human_edit_and_ai_improvement():
|
| 77 |
+
state = valid_minimal_job()
|
| 78 |
+
stage_id = "text_generation"
|
| 79 |
+
assert has_valid_human_approval(stage_id, state)
|
| 80 |
+
|
| 81 |
+
save_human_edits(state, stage_id, "edited text", "Reviewer", "Needs precision", "[]")
|
| 82 |
+
assert not has_valid_human_approval(stage_id, state)
|
| 83 |
+
assert any(approval.approval_status == "invalidated" for approval in state.approvals)
|
| 84 |
+
|
| 85 |
+
state = valid_minimal_job()
|
| 86 |
+
improve_with_ai(state, stage_id)
|
| 87 |
+
assert not has_valid_human_approval(stage_id, state)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_upstream_change_marks_downstream_stale_and_locks():
|
| 91 |
+
state = stale_downstream_job()
|
| 92 |
+
|
| 93 |
+
for stage_id in STAGE_IDS[STAGE_IDS.index("slide_outline_order") + 1 :]:
|
| 94 |
+
assert state.stages[stage_id].is_stale
|
| 95 |
+
assert "Stage is stale." in get_stage_lock_reasons(stage_id, state)
|
| 96 |
+
|
| 97 |
+
assert not can_unlock_next_stage("text_generation", state)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_objective_traceability_issues():
|
| 101 |
+
state = valid_minimal_job()
|
| 102 |
+
traces = compute_objective_traces(state)
|
| 103 |
+
assert all(trace.mapped_slide_ids for trace in traces)
|
| 104 |
+
assert not any(
|
| 105 |
+
issue.issue_type == IssueType.OBJECTIVE_UNCOVERED
|
| 106 |
+
for issue in state.issues.values()
|
| 107 |
+
if not issue.resolved
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
state = missing_objective_mapping_job()
|
| 111 |
+
traces = compute_objective_traces(state)
|
| 112 |
+
uncovered = [trace for trace in traces if trace.coverage_status == "uncovered"]
|
| 113 |
+
assert uncovered
|
| 114 |
+
assert any(
|
| 115 |
+
issue.issue_type == IssueType.OBJECTIVE_UNCOVERED
|
| 116 |
+
and issue.severity == IssueSeverity.BLOCKER
|
| 117 |
+
for issue in state.issues.values()
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
state = valid_minimal_job()
|
| 121 |
+
state.slides["slide_1"].objective_coverage_scores = {"obj_1": 40}
|
| 122 |
+
traces = compute_objective_traces(state)
|
| 123 |
+
assert any(trace.coverage_status == "weak" for trace in traces)
|
| 124 |
+
assert any(issue.issue_type == IssueType.OBJECTIVE_WEAKLY_COVERED for issue in state.issues.values())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_claim_support_blocks_technical_review_and_preflight():
|
| 128 |
+
state = valid_minimal_job()
|
| 129 |
+
assert validate_claim_support(state) == []
|
| 130 |
+
|
| 131 |
+
state = unsupported_claim_job()
|
| 132 |
+
issues = validate_claim_support(state)
|
| 133 |
+
assert issues
|
| 134 |
+
assert issues[0].severity == IssueSeverity.BLOCKER
|
| 135 |
+
|
| 136 |
+
result = grade_stage("technical_review", state)
|
| 137 |
+
assert not result.passed_threshold
|
| 138 |
+
|
| 139 |
+
report = run_export_preflight(state)
|
| 140 |
+
assert not report.can_export
|
| 141 |
+
assert any("unsupported_claim" in issue_id for issue_id in report.blocking_issue_ids)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_artifact_lifecycle_and_export_require_approved_current_artifacts():
|
| 145 |
+
state = build_empty_state(
|
| 146 |
+
deck_title="Lifecycle",
|
| 147 |
+
source_url="mock://source",
|
| 148 |
+
template_url="mock://template",
|
| 149 |
+
)
|
| 150 |
+
state, _message = generate_stage(state, "setup_inputs")
|
| 151 |
+
artifact = get_current_stage_artifact("setup_inputs", state)
|
| 152 |
+
|
| 153 |
+
assert artifact is not None
|
| 154 |
+
assert artifact.status == ArtifactStatus.CANDIDATE
|
| 155 |
+
|
| 156 |
+
state.stages["setup_inputs"].score = 90
|
| 157 |
+
approve_current_artifact(state, "setup_inputs")
|
| 158 |
+
assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.APPROVED
|
| 159 |
+
|
| 160 |
+
state = invalidated_approval_job()
|
| 161 |
+
report = run_export_preflight(state)
|
| 162 |
+
assert not report.can_export
|
| 163 |
+
|
| 164 |
+
state = stale_downstream_job()
|
| 165 |
+
report = run_export_preflight(state)
|
| 166 |
+
assert not report.can_export
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_preflight_fixture_matrix():
|
| 170 |
+
assert run_export_preflight(valid_minimal_job()).can_export
|
| 171 |
+
|
| 172 |
+
for fixture in [
|
| 173 |
+
missing_objective_mapping_job,
|
| 174 |
+
unsupported_claim_job,
|
| 175 |
+
missing_visual_asset_job,
|
| 176 |
+
invalid_layout_job,
|
| 177 |
+
stale_downstream_job,
|
| 178 |
+
invalidated_approval_job,
|
| 179 |
+
]:
|
| 180 |
+
report = run_export_preflight(fixture())
|
| 181 |
+
assert not report.can_export
|
| 182 |
+
|
| 183 |
+
state = valid_minimal_job()
|
| 184 |
+
state.production_export_requested = True
|
| 185 |
+
assert not run_export_preflight(state).can_export
|
| 186 |
+
|
| 187 |
+
state = valid_minimal_job()
|
| 188 |
+
state.mutation_target_url = state.source_url
|
| 189 |
+
assert not run_export_preflight(state).can_export
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_text_density_limits_and_cognitive_load():
|
| 193 |
+
state = valid_minimal_job()
|
| 194 |
+
assert check_text_density("slide_1", state) == []
|
| 195 |
+
|
| 196 |
+
state = valid_minimal_job()
|
| 197 |
+
state.slides["slide_1"].visible_text = " ".join(["word"] * 80)
|
| 198 |
+
issues = check_text_density("slide_1", state)
|
| 199 |
+
assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues)
|
| 200 |
+
|
| 201 |
+
state = valid_minimal_job()
|
| 202 |
+
state.slides["slide_1"].bullet_points = ["a", "b", "c", "d", "e"]
|
| 203 |
+
issues = check_text_density("slide_1", state)
|
| 204 |
+
assert any(issue.issue_type == IssueType.TEXT_DENSITY_EXCEEDED for issue in issues)
|
| 205 |
+
|
| 206 |
+
state = valid_minimal_job()
|
| 207 |
+
state.slides["slide_1"].objective_ids = ["obj_1", "obj_2", "obj_3"]
|
| 208 |
+
issues = check_text_density("slide_1", state)
|
| 209 |
+
assert any(issue.issue_type == IssueType.COGNITIVE_LOAD_HIGH for issue in issues)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_layout_validation_schema_and_slots():
|
| 213 |
+
state = valid_minimal_job()
|
| 214 |
+
assert validate_layout_spec(state.layout_specs["slide_1"], state) == []
|
| 215 |
+
|
| 216 |
+
state = valid_minimal_job()
|
| 217 |
+
unknown = LayoutSpec(slide_id="slide_1", layout_id="unknown", slot_assignments={})
|
| 218 |
+
issues = validate_layout_spec(unknown, state)
|
| 219 |
+
assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues)
|
| 220 |
+
|
| 221 |
+
state = valid_minimal_job()
|
| 222 |
+
missing = LayoutSpec(
|
| 223 |
+
slide_id="slide_1",
|
| 224 |
+
layout_id="title_bullets_visual",
|
| 225 |
+
slot_assignments={"title": "Only title"},
|
| 226 |
+
)
|
| 227 |
+
issues = validate_layout_spec(missing, state)
|
| 228 |
+
assert any(issue.issue_type == IssueType.LAYOUT_SLOT_VIOLATION for issue in issues)
|
| 229 |
+
|
| 230 |
+
state = valid_minimal_job()
|
| 231 |
+
raw = LayoutSpec(slide_id="slide_1", layout_id="title_body", slot_assignments={"x": 1})
|
| 232 |
+
issues = validate_layout_spec(raw, state)
|
| 233 |
+
assert any(issue.issue_type == IssueType.LAYOUT_SCHEMA_INVALID for issue in issues)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def test_final_export_marks_approved_artifacts_exported_and_blocks_failures():
|
| 237 |
+
state = valid_minimal_job()
|
| 238 |
+
state, message = final_render_export(state)
|
| 239 |
+
assert "completed" in message
|
| 240 |
+
assert get_current_stage_artifact("setup_inputs", state).status == ArtifactStatus.EXPORTED
|
| 241 |
+
|
| 242 |
+
state = unsupported_claim_job()
|
| 243 |
+
state, message = final_render_export(state)
|
| 244 |
+
assert "failed" in message
|
| 245 |
+
assert any(event.event_type == "export_blocked_by_preflight" for event in state.audit_events)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def test_artifact_diff_supports_text_and_json():
|
| 249 |
+
text_diff = compute_artifact_diff("alpha\n", "beta\n")
|
| 250 |
+
json_diff = compute_artifact_diff({"b": 2, "a": 1}, {"a": 1, "b": 3})
|
| 251 |
+
|
| 252 |
+
assert "-alpha" in text_diff
|
| 253 |
+
assert '+ "b": 3' in json_diff
|