rigelbar commited on
Commit
c5d7500
·
1 Parent(s): f174673

Implement P0 slide workflow foundation

Browse files
.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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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