Spaces:
Sleeping
Sleeping
Add material upload and draft import setup
Browse files- README.md +4 -0
- app.py +54 -12
- course_slide_factory/models.py +4 -0
- course_slide_factory/quality.py +10 -6
- course_slide_factory/workflow.py +325 -3
- requirements.txt +1 -0
- tests/test_p0_quality.py +158 -0
README.md
CHANGED
|
@@ -17,6 +17,10 @@ short_description: 'Slide creation AI assistant '
|
|
| 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
|
|
|
|
| 17 |
Gradio workflow console for generating, reviewing, approving, and export-checking
|
| 18 |
course slide decks with deterministic P0 trust and readiness gates.
|
| 19 |
|
| 20 |
+
In setup, you can provide course material by uploading text-readable files or by
|
| 21 |
+
specifying a material URL. Either option is acceptable. You can also start from
|
| 22 |
+
zero or import an existing draft deck as `.json` or `.pptx`.
|
| 23 |
+
|
| 24 |
## Local checks
|
| 25 |
|
| 26 |
```bash
|
app.py
CHANGED
|
@@ -46,7 +46,9 @@ def _load_or_update_state(
|
|
| 46 |
output_folder_id: str,
|
| 47 |
dry_run: bool,
|
| 48 |
objectives_text: str,
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
mutation_target_url: str,
|
| 51 |
production_export_requested: bool,
|
| 52 |
):
|
|
@@ -69,7 +71,9 @@ def _load_or_update_state(
|
|
| 69 |
output_folder_id=output_folder_id,
|
| 70 |
dry_run=dry_run,
|
| 71 |
objectives_text=objectives_text,
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
mutation_target_url=mutation_target_url or None,
|
| 74 |
production_export_requested=production_export_requested,
|
| 75 |
)
|
|
@@ -104,7 +108,9 @@ def handle_update_setup(
|
|
| 104 |
output_folder_id,
|
| 105 |
dry_run,
|
| 106 |
objectives_text,
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
mutation_target_url,
|
| 109 |
production_export_requested,
|
| 110 |
stage_id,
|
|
@@ -117,7 +123,9 @@ def handle_update_setup(
|
|
| 117 |
output_folder_id,
|
| 118 |
dry_run,
|
| 119 |
objectives_text,
|
| 120 |
-
|
|
|
|
|
|
|
| 121 |
mutation_target_url,
|
| 122 |
production_export_requested,
|
| 123 |
)
|
|
@@ -132,7 +140,9 @@ def handle_generate(
|
|
| 132 |
output_folder_id,
|
| 133 |
dry_run,
|
| 134 |
objectives_text,
|
| 135 |
-
|
|
|
|
|
|
|
| 136 |
mutation_target_url,
|
| 137 |
production_export_requested,
|
| 138 |
stage_id,
|
|
@@ -145,7 +155,9 @@ def handle_generate(
|
|
| 145 |
output_folder_id,
|
| 146 |
dry_run,
|
| 147 |
objectives_text,
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
mutation_target_url,
|
| 150 |
production_export_requested,
|
| 151 |
)
|
|
@@ -211,6 +223,10 @@ def handle_stage_change(state_data, stage_id):
|
|
| 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:
|
|
@@ -220,7 +236,19 @@ def build_app() -> gr.Blocks:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="")
|
|
@@ -235,10 +263,21 @@ def build_app() -> gr.Blocks:
|
|
| 235 |
"obj_2: Apply the concept to a worked example."
|
| 236 |
),
|
| 237 |
)
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
)
|
| 243 |
update_setup = gr.Button("Save Setup", variant="secondary")
|
| 244 |
|
|
@@ -392,7 +431,9 @@ def build_app() -> gr.Blocks:
|
|
| 392 |
output_folder_id,
|
| 393 |
dry_run,
|
| 394 |
objectives_text,
|
| 395 |
-
|
|
|
|
|
|
|
| 396 |
mutation_target_url,
|
| 397 |
production_export_requested,
|
| 398 |
stage_id,
|
|
@@ -424,6 +465,7 @@ def build_app() -> gr.Blocks:
|
|
| 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 |
|
|
|
|
| 46 |
output_folder_id: str,
|
| 47 |
dry_run: bool,
|
| 48 |
objectives_text: str,
|
| 49 |
+
material_files,
|
| 50 |
+
start_mode: str,
|
| 51 |
+
draft_file,
|
| 52 |
mutation_target_url: str,
|
| 53 |
production_export_requested: bool,
|
| 54 |
):
|
|
|
|
| 71 |
output_folder_id=output_folder_id,
|
| 72 |
dry_run=dry_run,
|
| 73 |
objectives_text=objectives_text,
|
| 74 |
+
material_files=material_files,
|
| 75 |
+
start_mode=start_mode,
|
| 76 |
+
draft_file=draft_file,
|
| 77 |
mutation_target_url=mutation_target_url or None,
|
| 78 |
production_export_requested=production_export_requested,
|
| 79 |
)
|
|
|
|
| 108 |
output_folder_id,
|
| 109 |
dry_run,
|
| 110 |
objectives_text,
|
| 111 |
+
material_files,
|
| 112 |
+
start_mode,
|
| 113 |
+
draft_file,
|
| 114 |
mutation_target_url,
|
| 115 |
production_export_requested,
|
| 116 |
stage_id,
|
|
|
|
| 123 |
output_folder_id,
|
| 124 |
dry_run,
|
| 125 |
objectives_text,
|
| 126 |
+
material_files,
|
| 127 |
+
start_mode,
|
| 128 |
+
draft_file,
|
| 129 |
mutation_target_url,
|
| 130 |
production_export_requested,
|
| 131 |
)
|
|
|
|
| 140 |
output_folder_id,
|
| 141 |
dry_run,
|
| 142 |
objectives_text,
|
| 143 |
+
material_files,
|
| 144 |
+
start_mode,
|
| 145 |
+
draft_file,
|
| 146 |
mutation_target_url,
|
| 147 |
production_export_requested,
|
| 148 |
stage_id,
|
|
|
|
| 155 |
output_folder_id,
|
| 156 |
dry_run,
|
| 157 |
objectives_text,
|
| 158 |
+
material_files,
|
| 159 |
+
start_mode,
|
| 160 |
+
draft_file,
|
| 161 |
mutation_target_url,
|
| 162 |
production_export_requested,
|
| 163 |
)
|
|
|
|
| 223 |
return _render_outputs(state, stage_id, f"Selected {STAGE_LABELS[stage_id]}.")
|
| 224 |
|
| 225 |
|
| 226 |
+
def toggle_draft_upload(start_mode):
|
| 227 |
+
return gr.update(visible=start_mode == "import_existing_draft")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def build_app() -> gr.Blocks:
|
| 231 |
stage_choices = [(f"{index + 1}. {STAGE_LABELS[stage_id]}", stage_id) for index, stage_id in enumerate(STAGE_IDS)]
|
| 232 |
with gr.Blocks(title="Course Slide Factory") as demo:
|
|
|
|
| 236 |
with gr.Row():
|
| 237 |
with gr.Column(scale=1, min_width=320):
|
| 238 |
deck_title = gr.Textbox(label="Deck title", value="Course Slide Deck")
|
| 239 |
+
gr.Markdown(
|
| 240 |
+
"### Course material\n"
|
| 241 |
+
"Use either option: upload material, specify a URL, or provide both. "
|
| 242 |
+
"Uploads are used directly when text-readable; URLs are stored for provenance "
|
| 243 |
+
"until URL ingestion is added."
|
| 244 |
+
)
|
| 245 |
+
material_files = gr.File(
|
| 246 |
+
label="Upload material",
|
| 247 |
+
file_count="multiple",
|
| 248 |
+
file_types=[".txt", ".md", ".csv", ".json", ".pdf", ".docx", ".pptx"],
|
| 249 |
+
type="filepath",
|
| 250 |
+
)
|
| 251 |
+
source_url = gr.Textbox(label="Material URL", value="mock://source/course")
|
| 252 |
template_url = gr.Textbox(label="Template URL", value="mock://template/course")
|
| 253 |
output_folder_id = gr.Textbox(label="Output folder ID", value="")
|
| 254 |
mutation_target_url = gr.Textbox(label="Mutation target URL", value="")
|
|
|
|
| 263 |
"obj_2: Apply the concept to a worked example."
|
| 264 |
),
|
| 265 |
)
|
| 266 |
+
gr.Markdown("### Starting point\nYou can start from zero or import an existing draft.")
|
| 267 |
+
start_mode = gr.Radio(
|
| 268 |
+
label="Start mode",
|
| 269 |
+
choices=[
|
| 270 |
+
("Start from zero", "start_from_zero"),
|
| 271 |
+
("Import existing draft", "import_existing_draft"),
|
| 272 |
+
],
|
| 273 |
+
value="start_from_zero",
|
| 274 |
+
)
|
| 275 |
+
draft_file = gr.File(
|
| 276 |
+
label="Existing draft deck (.json or .pptx)",
|
| 277 |
+
file_count="single",
|
| 278 |
+
file_types=[".json", ".pptx"],
|
| 279 |
+
type="filepath",
|
| 280 |
+
visible=False,
|
| 281 |
)
|
| 282 |
update_setup = gr.Button("Save Setup", variant="secondary")
|
| 283 |
|
|
|
|
| 431 |
output_folder_id,
|
| 432 |
dry_run,
|
| 433 |
objectives_text,
|
| 434 |
+
material_files,
|
| 435 |
+
start_mode,
|
| 436 |
+
draft_file,
|
| 437 |
mutation_target_url,
|
| 438 |
production_export_requested,
|
| 439 |
stage_id,
|
|
|
|
| 465 |
preflight.click(handle_preflight, inputs=[state_store, stage_id], outputs=outputs)
|
| 466 |
export.click(handle_export, inputs=[state_store, stage_id], outputs=outputs)
|
| 467 |
stage_id.change(handle_stage_change, inputs=[state_store, stage_id], outputs=outputs)
|
| 468 |
+
start_mode.change(toggle_draft_upload, inputs=start_mode, outputs=draft_file)
|
| 469 |
|
| 470 |
return demo
|
| 471 |
|
course_slide_factory/models.py
CHANGED
|
@@ -332,6 +332,10 @@ class PipelineState(BaseModel):
|
|
| 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)
|
|
|
|
| 332 |
template_url: str | None = None
|
| 333 |
output_folder_id: str | None = None
|
| 334 |
mutation_target_url: str | None = None
|
| 335 |
+
uploaded_materials: list[dict[str, Any]] = Field(default_factory=list)
|
| 336 |
+
unsupported_materials: list[dict[str, Any]] = Field(default_factory=list)
|
| 337 |
+
start_mode: Literal["start_from_zero", "import_existing_draft"] = "start_from_zero"
|
| 338 |
+
draft_upload_metadata: dict[str, Any] = Field(default_factory=dict)
|
| 339 |
source_chunks: dict[str, str] = Field(default_factory=dict)
|
| 340 |
objectives: dict[str, str] = Field(default_factory=dict)
|
| 341 |
stages: dict[str, StageState] = Field(default_factory=dict)
|
course_slide_factory/quality.py
CHANGED
|
@@ -942,13 +942,17 @@ def _deduct_for_issue_types(
|
|
| 942 |
|
| 943 |
def _score_dimension(stage_id: str, dimension_id: str, state: PipelineState) -> tuple[int, str]:
|
| 944 |
if stage_id == "setup_inputs":
|
| 945 |
-
|
|
|
|
| 946 |
if dimension_id == "required_inputs_present":
|
| 947 |
-
return (
|
|
|
|
|
|
|
|
|
|
| 948 |
if dimension_id == "url_validity":
|
| 949 |
-
urls = [state.source_url
|
| 950 |
valid = all(url.startswith(("http://", "https://", "mock://")) for url in urls)
|
| 951 |
-
return (100 if valid else 40, "
|
| 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
|
|
@@ -1259,12 +1263,12 @@ def _preflight_blocker(
|
|
| 1259 |
|
| 1260 |
|
| 1261 |
def _check_preflight_prerequisites(state: PipelineState) -> None:
|
| 1262 |
-
if not state.deck_title or not state.
|
| 1263 |
upsert_issue(
|
| 1264 |
state,
|
| 1265 |
IssueType.MISSING_REQUIRED_INPUT,
|
| 1266 |
IssueSeverity.BLOCKER,
|
| 1267 |
-
"Deck title,
|
| 1268 |
stage_id="setup_inputs",
|
| 1269 |
suggested_fix="Complete setup inputs before export.",
|
| 1270 |
)
|
|
|
|
| 942 |
|
| 943 |
def _score_dimension(stage_id: str, dimension_id: str, state: PipelineState) -> tuple[int, str]:
|
| 944 |
if stage_id == "setup_inputs":
|
| 945 |
+
source_material_present = bool(state.source_url or state.source_chunks)
|
| 946 |
+
required = [state.deck_title, state.template_url, source_material_present]
|
| 947 |
if dimension_id == "required_inputs_present":
|
| 948 |
+
return (
|
| 949 |
+
100 if all(required) else 30,
|
| 950 |
+
"Deck title, template URL, and either uploaded material or a material URL are checked.",
|
| 951 |
+
)
|
| 952 |
if dimension_id == "url_validity":
|
| 953 |
+
urls = [url for url in [state.source_url, state.template_url] if url]
|
| 954 |
valid = all(url.startswith(("http://", "https://", "mock://")) for url in urls)
|
| 955 |
+
return (100 if valid else 40, "Provided material/template URLs must be explicit URLs.")
|
| 956 |
safe = state.dry_run or bool(state.output_folder_id)
|
| 957 |
if state.mutation_target_url and state.mutation_target_url in {state.source_url, state.template_url}:
|
| 958 |
safe = False
|
|
|
|
| 1263 |
|
| 1264 |
|
| 1265 |
def _check_preflight_prerequisites(state: PipelineState) -> None:
|
| 1266 |
+
if not state.deck_title or not state.template_url or not (state.source_url or state.source_chunks):
|
| 1267 |
upsert_issue(
|
| 1268 |
state,
|
| 1269 |
IssueType.MISSING_REQUIRED_INPUT,
|
| 1270 |
IssueSeverity.BLOCKER,
|
| 1271 |
+
"Deck title, template URL, and course material upload or URL are required.",
|
| 1272 |
stage_id="setup_inputs",
|
| 1273 |
suggested_fix="Complete setup inputs before export.",
|
| 1274 |
)
|
course_slide_factory/workflow.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 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
|
|
@@ -93,6 +94,319 @@ def parse_objective_lines(objectives_text: str | None) -> dict[str, str]:
|
|
| 93 |
return objectives
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def update_setup_from_inputs(
|
| 97 |
state: PipelineState,
|
| 98 |
*,
|
|
@@ -102,7 +416,10 @@ def update_setup_from_inputs(
|
|
| 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:
|
|
@@ -116,8 +433,13 @@ def update_setup_from_inputs(
|
|
| 116 |
parsed_objectives = parse_objective_lines(objectives_text)
|
| 117 |
if parsed_objectives:
|
| 118 |
state.objectives = parsed_objectives
|
| 119 |
-
|
| 120 |
-
state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return state
|
| 122 |
|
| 123 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
from .constants import STAGE_IDS, STAGE_LABELS, STAGE_SEQUENCE
|
|
|
|
| 94 |
return objectives
|
| 95 |
|
| 96 |
|
| 97 |
+
def _normalize_file_paths(file_paths: Any) -> list[Path]:
|
| 98 |
+
if not file_paths:
|
| 99 |
+
return []
|
| 100 |
+
if isinstance(file_paths, str | Path):
|
| 101 |
+
return [Path(file_paths)]
|
| 102 |
+
normalized: list[Path] = []
|
| 103 |
+
for item in file_paths:
|
| 104 |
+
if isinstance(item, str | Path):
|
| 105 |
+
normalized.append(Path(item))
|
| 106 |
+
continue
|
| 107 |
+
path = getattr(item, "path", None) or getattr(item, "name", None)
|
| 108 |
+
if path:
|
| 109 |
+
normalized.append(Path(path))
|
| 110 |
+
return normalized
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _file_content_hash(path: Path) -> str:
|
| 114 |
+
return stable_hash({"path": path.name, "bytes": path.read_bytes().hex()})
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _read_text_file(path: Path) -> str:
|
| 118 |
+
return path.read_text(encoding="utf-8", errors="replace").strip()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def extract_uploaded_source_chunks(file_paths: Any) -> tuple[dict[str, str], list[dict[str, Any]], list[dict[str, Any]]]:
|
| 122 |
+
chunks: dict[str, str] = {}
|
| 123 |
+
parsed_materials: list[dict[str, Any]] = []
|
| 124 |
+
unsupported_materials: list[dict[str, Any]] = []
|
| 125 |
+
for index, path in enumerate(_normalize_file_paths(file_paths), start=1):
|
| 126 |
+
suffix = path.suffix.lower()
|
| 127 |
+
metadata = {
|
| 128 |
+
"filename": path.name,
|
| 129 |
+
"path": str(path),
|
| 130 |
+
"extension": suffix,
|
| 131 |
+
}
|
| 132 |
+
if suffix in {".txt", ".md", ".csv"}:
|
| 133 |
+
text = _read_text_file(path)
|
| 134 |
+
if text:
|
| 135 |
+
chunk_id = f"upload_{index}"
|
| 136 |
+
chunks[chunk_id] = text
|
| 137 |
+
parsed_materials.append({**metadata, "chunk_id": chunk_id, "parsed": True})
|
| 138 |
+
continue
|
| 139 |
+
if suffix == ".json":
|
| 140 |
+
raw_text = _read_text_file(path)
|
| 141 |
+
if not raw_text:
|
| 142 |
+
continue
|
| 143 |
+
parsed = json.loads(raw_text)
|
| 144 |
+
chunk_id = f"upload_{index}"
|
| 145 |
+
chunks[chunk_id] = json.dumps(parsed, indent=2, sort_keys=True)
|
| 146 |
+
parsed_materials.append({**metadata, "chunk_id": chunk_id, "parsed": True})
|
| 147 |
+
continue
|
| 148 |
+
unsupported_materials.append(
|
| 149 |
+
{
|
| 150 |
+
**metadata,
|
| 151 |
+
"parsed": False,
|
| 152 |
+
"reason": "Only .txt, .md, .csv, and .json material uploads are parsed in this version.",
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
return chunks, parsed_materials, unsupported_materials
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _sequence_items(value: Any) -> list[Any]:
|
| 159 |
+
if value is None:
|
| 160 |
+
return []
|
| 161 |
+
if isinstance(value, dict):
|
| 162 |
+
return list(value.values())
|
| 163 |
+
if isinstance(value, list):
|
| 164 |
+
return value
|
| 165 |
+
return []
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _import_json_draft(path: Path) -> dict[str, Any]:
|
| 169 |
+
data = json.loads(_read_text_file(path))
|
| 170 |
+
if not isinstance(data, dict):
|
| 171 |
+
raise ValueError("Draft JSON must be an object.")
|
| 172 |
+
|
| 173 |
+
objectives = data.get("objectives") or {}
|
| 174 |
+
source_chunks = data.get("source_chunks") or {}
|
| 175 |
+
slides = [
|
| 176 |
+
model_validate(Slide, item)
|
| 177 |
+
for item in _sequence_items(data.get("slides"))
|
| 178 |
+
]
|
| 179 |
+
claims = [
|
| 180 |
+
model_validate(SlideClaim, item)
|
| 181 |
+
for item in _sequence_items(data.get("claims"))
|
| 182 |
+
]
|
| 183 |
+
visual_assets = [
|
| 184 |
+
model_validate(VisualAsset, item)
|
| 185 |
+
for item in _sequence_items(data.get("visual_assets"))
|
| 186 |
+
]
|
| 187 |
+
layout_specs = [
|
| 188 |
+
model_validate(LayoutSpec, item)
|
| 189 |
+
for item in _sequence_items(data.get("layout_specs"))
|
| 190 |
+
]
|
| 191 |
+
return {
|
| 192 |
+
"objectives": objectives,
|
| 193 |
+
"source_chunks": source_chunks,
|
| 194 |
+
"slides": slides,
|
| 195 |
+
"claims": claims,
|
| 196 |
+
"visual_assets": visual_assets,
|
| 197 |
+
"layout_specs": layout_specs,
|
| 198 |
+
"metadata": {"format": "json", "filename": path.name},
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _slide_notes_text(slide: Any) -> str | None:
|
| 203 |
+
try:
|
| 204 |
+
notes_slide = slide.notes_slide
|
| 205 |
+
text_frame = getattr(notes_slide, "notes_text_frame", None)
|
| 206 |
+
if text_frame and getattr(text_frame, "text", None):
|
| 207 |
+
return text_frame.text.strip()
|
| 208 |
+
except (AttributeError, KeyError, ValueError):
|
| 209 |
+
return None
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _import_pptx_draft(path: Path) -> dict[str, Any]:
|
| 214 |
+
try:
|
| 215 |
+
from pptx import Presentation
|
| 216 |
+
except ImportError as exc:
|
| 217 |
+
raise RuntimeError("python-pptx is required to import PPTX drafts.") from exc
|
| 218 |
+
|
| 219 |
+
presentation = Presentation(str(path))
|
| 220 |
+
slides: list[Slide] = []
|
| 221 |
+
for index, pptx_slide in enumerate(presentation.slides, start=1):
|
| 222 |
+
text_runs: list[str] = []
|
| 223 |
+
title = None
|
| 224 |
+
if pptx_slide.shapes.title and pptx_slide.shapes.title.has_text_frame:
|
| 225 |
+
title = pptx_slide.shapes.title.text.strip() or None
|
| 226 |
+
for shape in pptx_slide.shapes:
|
| 227 |
+
if not getattr(shape, "has_text_frame", False):
|
| 228 |
+
continue
|
| 229 |
+
text = shape.text.strip()
|
| 230 |
+
if text:
|
| 231 |
+
text_runs.append(text)
|
| 232 |
+
deduped_text = [text for text in text_runs if text != title]
|
| 233 |
+
bullet_points = []
|
| 234 |
+
for text in deduped_text:
|
| 235 |
+
bullet_points.extend([line.strip() for line in text.splitlines() if line.strip()])
|
| 236 |
+
slide_id = f"slide_{index}"
|
| 237 |
+
notes_text = _slide_notes_text(pptx_slide)
|
| 238 |
+
slides.append(
|
| 239 |
+
Slide(
|
| 240 |
+
slide_id=slide_id,
|
| 241 |
+
slide_number=index,
|
| 242 |
+
title=title or f"Imported slide {index}",
|
| 243 |
+
visible_text="\n".join(deduped_text),
|
| 244 |
+
bullet_points=bullet_points,
|
| 245 |
+
pedagogical_role=PedagogicalRole.UNKNOWN,
|
| 246 |
+
speaker_notes=(
|
| 247 |
+
SpeakerNotes(slide_id=slide_id, notes_text=notes_text)
|
| 248 |
+
if notes_text
|
| 249 |
+
else None
|
| 250 |
+
),
|
| 251 |
+
)
|
| 252 |
+
)
|
| 253 |
+
return {
|
| 254 |
+
"objectives": {},
|
| 255 |
+
"source_chunks": {},
|
| 256 |
+
"slides": slides,
|
| 257 |
+
"claims": [],
|
| 258 |
+
"visual_assets": [],
|
| 259 |
+
"layout_specs": [],
|
| 260 |
+
"metadata": {"format": "pptx", "filename": path.name},
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def import_existing_draft(file_path: Any) -> dict[str, Any]:
|
| 265 |
+
paths = _normalize_file_paths(file_path)
|
| 266 |
+
if not paths:
|
| 267 |
+
return {}
|
| 268 |
+
path = paths[0]
|
| 269 |
+
suffix = path.suffix.lower()
|
| 270 |
+
if suffix == ".json":
|
| 271 |
+
return _import_json_draft(path)
|
| 272 |
+
if suffix == ".pptx":
|
| 273 |
+
return _import_pptx_draft(path)
|
| 274 |
+
raise ValueError("Existing draft upload must be a .json or .pptx file.")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _apply_imported_draft(state: PipelineState, draft: dict[str, Any], file_hash: str) -> None:
|
| 278 |
+
if draft.get("objectives"):
|
| 279 |
+
state.objectives = dict(draft["objectives"])
|
| 280 |
+
if draft.get("source_chunks"):
|
| 281 |
+
state.source_chunks.update(dict(draft["source_chunks"]))
|
| 282 |
+
for slide in draft.get("slides", []):
|
| 283 |
+
state.slides[slide.slide_id] = slide
|
| 284 |
+
for claim in draft.get("claims", []):
|
| 285 |
+
state.claims[claim.claim_id] = claim
|
| 286 |
+
for asset in draft.get("visual_assets", []):
|
| 287 |
+
state.visual_assets[asset.asset_id] = asset
|
| 288 |
+
for layout in draft.get("layout_specs", []):
|
| 289 |
+
state.layout_specs[layout.slide_id] = layout
|
| 290 |
+
|
| 291 |
+
imported_content = {
|
| 292 |
+
"slides": [model_to_dict(slide) for slide in draft.get("slides", [])],
|
| 293 |
+
"claims": [model_to_dict(claim) for claim in draft.get("claims", [])],
|
| 294 |
+
"visual_assets": [model_to_dict(asset) for asset in draft.get("visual_assets", [])],
|
| 295 |
+
"layout_specs": [model_to_dict(layout) for layout in draft.get("layout_specs", [])],
|
| 296 |
+
"metadata": draft.get("metadata", {}),
|
| 297 |
+
}
|
| 298 |
+
created_stage_ids: list[str] = []
|
| 299 |
+
if draft.get("objectives") or draft.get("source_chunks"):
|
| 300 |
+
create_artifact_version(
|
| 301 |
+
state,
|
| 302 |
+
"source_extraction_objective_mapping",
|
| 303 |
+
{
|
| 304 |
+
"objectives": state.objectives,
|
| 305 |
+
"source_chunks": state.source_chunks,
|
| 306 |
+
"import_metadata": draft.get("metadata", {}),
|
| 307 |
+
},
|
| 308 |
+
created_by="human",
|
| 309 |
+
status=ArtifactStatus.CANDIDATE,
|
| 310 |
+
mark_downstream_stale=False,
|
| 311 |
+
)
|
| 312 |
+
created_stage_ids.append("source_extraction_objective_mapping")
|
| 313 |
+
if draft.get("slides"):
|
| 314 |
+
create_artifact_version(
|
| 315 |
+
state,
|
| 316 |
+
"slide_outline_order",
|
| 317 |
+
imported_content,
|
| 318 |
+
created_by="human",
|
| 319 |
+
status=ArtifactStatus.CANDIDATE,
|
| 320 |
+
mark_downstream_stale=False,
|
| 321 |
+
)
|
| 322 |
+
create_artifact_version(
|
| 323 |
+
state,
|
| 324 |
+
"title_generation",
|
| 325 |
+
{"titles": {slide.slide_id: slide.title for slide in draft["slides"]}},
|
| 326 |
+
created_by="human",
|
| 327 |
+
status=ArtifactStatus.CANDIDATE,
|
| 328 |
+
mark_downstream_stale=False,
|
| 329 |
+
)
|
| 330 |
+
create_artifact_version(
|
| 331 |
+
state,
|
| 332 |
+
"text_generation",
|
| 333 |
+
imported_content,
|
| 334 |
+
created_by="human",
|
| 335 |
+
status=ArtifactStatus.CANDIDATE,
|
| 336 |
+
mark_downstream_stale=False,
|
| 337 |
+
)
|
| 338 |
+
created_stage_ids.extend(["slide_outline_order", "title_generation", "text_generation"])
|
| 339 |
+
if draft.get("visual_assets"):
|
| 340 |
+
create_artifact_version(
|
| 341 |
+
state,
|
| 342 |
+
"image_visual_asset_generation",
|
| 343 |
+
imported_content,
|
| 344 |
+
created_by="human",
|
| 345 |
+
status=ArtifactStatus.CANDIDATE,
|
| 346 |
+
mark_downstream_stale=False,
|
| 347 |
+
)
|
| 348 |
+
created_stage_ids.append("image_visual_asset_generation")
|
| 349 |
+
if draft.get("layout_specs"):
|
| 350 |
+
create_artifact_version(
|
| 351 |
+
state,
|
| 352 |
+
"aesthetic_ordering_visual_composition",
|
| 353 |
+
imported_content,
|
| 354 |
+
created_by="human",
|
| 355 |
+
status=ArtifactStatus.CANDIDATE,
|
| 356 |
+
mark_downstream_stale=False,
|
| 357 |
+
)
|
| 358 |
+
created_stage_ids.append("aesthetic_ordering_visual_composition")
|
| 359 |
+
|
| 360 |
+
state.draft_upload_metadata = {
|
| 361 |
+
**draft.get("metadata", {}),
|
| 362 |
+
"content_hash": file_hash,
|
| 363 |
+
"created_stage_ids": created_stage_ids,
|
| 364 |
+
"imported_at": now_iso(),
|
| 365 |
+
}
|
| 366 |
+
record_audit(
|
| 367 |
+
state,
|
| 368 |
+
"draft_imported",
|
| 369 |
+
metadata={
|
| 370 |
+
"filename": state.draft_upload_metadata.get("filename"),
|
| 371 |
+
"format": state.draft_upload_metadata.get("format"),
|
| 372 |
+
"created_stage_ids": created_stage_ids,
|
| 373 |
+
},
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def apply_setup_material_inputs(
|
| 378 |
+
state: PipelineState,
|
| 379 |
+
*,
|
| 380 |
+
material_files: Any = None,
|
| 381 |
+
source_text: str | None = None,
|
| 382 |
+
start_mode: str = "start_from_zero",
|
| 383 |
+
draft_file: Any = None,
|
| 384 |
+
) -> PipelineState:
|
| 385 |
+
state.start_mode = (
|
| 386 |
+
"import_existing_draft"
|
| 387 |
+
if start_mode == "import_existing_draft"
|
| 388 |
+
else "start_from_zero"
|
| 389 |
+
)
|
| 390 |
+
uploaded_chunks, parsed_materials, unsupported_materials = extract_uploaded_source_chunks(material_files)
|
| 391 |
+
if uploaded_chunks:
|
| 392 |
+
state.source_chunks = uploaded_chunks
|
| 393 |
+
if parsed_materials:
|
| 394 |
+
state.uploaded_materials = parsed_materials
|
| 395 |
+
if unsupported_materials:
|
| 396 |
+
state.unsupported_materials = unsupported_materials
|
| 397 |
+
if source_text and source_text.strip():
|
| 398 |
+
state.source_chunks = {"pasted_text": source_text.strip(), **state.source_chunks}
|
| 399 |
+
|
| 400 |
+
if state.start_mode == "import_existing_draft":
|
| 401 |
+
draft_paths = _normalize_file_paths(draft_file)
|
| 402 |
+
if draft_paths:
|
| 403 |
+
file_hash = _file_content_hash(draft_paths[0])
|
| 404 |
+
if state.draft_upload_metadata.get("content_hash") != file_hash:
|
| 405 |
+
draft = import_existing_draft(draft_paths[0])
|
| 406 |
+
_apply_imported_draft(state, draft, file_hash)
|
| 407 |
+
return state
|
| 408 |
+
|
| 409 |
+
|
| 410 |
def update_setup_from_inputs(
|
| 411 |
state: PipelineState,
|
| 412 |
*,
|
|
|
|
| 416 |
output_folder_id: str | None,
|
| 417 |
dry_run: bool,
|
| 418 |
objectives_text: str | None,
|
| 419 |
+
source_text: str | None = None,
|
| 420 |
+
material_files: Any = None,
|
| 421 |
+
start_mode: str = "start_from_zero",
|
| 422 |
+
draft_file: Any = None,
|
| 423 |
mutation_target_url: str | None = None,
|
| 424 |
production_export_requested: bool = False,
|
| 425 |
) -> PipelineState:
|
|
|
|
| 433 |
parsed_objectives = parse_objective_lines(objectives_text)
|
| 434 |
if parsed_objectives:
|
| 435 |
state.objectives = parsed_objectives
|
| 436 |
+
apply_setup_material_inputs(
|
| 437 |
+
state,
|
| 438 |
+
material_files=material_files,
|
| 439 |
+
source_text=source_text,
|
| 440 |
+
start_mode=start_mode,
|
| 441 |
+
draft_file=draft_file,
|
| 442 |
+
)
|
| 443 |
return state
|
| 444 |
|
| 445 |
|
requirements.txt
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
gradio==6.19.0
|
| 2 |
pydantic>=2.7
|
|
|
|
| 3 |
pytest>=8.0
|
| 4 |
ruff>=0.8
|
|
|
|
| 1 |
gradio==6.19.0
|
| 2 |
pydantic>=2.7
|
| 3 |
+
python-pptx>=1.0.2
|
| 4 |
pytest>=8.0
|
| 5 |
ruff>=0.8
|
tests/test_p0_quality.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 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,
|
|
@@ -33,6 +35,7 @@ from course_slide_factory.workflow import (
|
|
| 33 |
generate_stage,
|
| 34 |
improve_with_ai,
|
| 35 |
save_human_edits,
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
|
|
@@ -251,3 +254,158 @@ def test_artifact_diff_supports_text_and_json():
|
|
| 251 |
|
| 252 |
assert "-alpha" in text_diff
|
| 253 |
assert '+ "b": 3' in json_diff
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
from course_slide_factory.constants import STAGE_IDS
|
| 6 |
from course_slide_factory.fixtures import (
|
| 7 |
invalid_layout_job,
|
|
|
|
| 35 |
generate_stage,
|
| 36 |
improve_with_ai,
|
| 37 |
save_human_edits,
|
| 38 |
+
update_setup_from_inputs,
|
| 39 |
)
|
| 40 |
|
| 41 |
|
|
|
|
| 254 |
|
| 255 |
assert "-alpha" in text_diff
|
| 256 |
assert '+ "b": 3' in json_diff
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_setup_passes_with_uploaded_text_material_only(tmp_path):
|
| 260 |
+
material_path = tmp_path / "course-notes.md"
|
| 261 |
+
material_path.write_text("These uploaded notes describe the course objective.", encoding="utf-8")
|
| 262 |
+
state = build_empty_state()
|
| 263 |
+
|
| 264 |
+
update_setup_from_inputs(
|
| 265 |
+
state,
|
| 266 |
+
deck_title="Uploaded Material Deck",
|
| 267 |
+
source_url=None,
|
| 268 |
+
template_url="mock://template/course",
|
| 269 |
+
output_folder_id=None,
|
| 270 |
+
dry_run=True,
|
| 271 |
+
objectives_text="obj_1: Explain uploaded source material.",
|
| 272 |
+
material_files=[str(material_path)],
|
| 273 |
+
)
|
| 274 |
+
result = grade_stage("setup_inputs", state)
|
| 275 |
+
|
| 276 |
+
assert result.passed_threshold
|
| 277 |
+
assert state.source_chunks["upload_1"].startswith("These uploaded notes")
|
| 278 |
+
assert state.uploaded_materials[0]["parsed"] is True
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def test_setup_passes_with_material_url_only():
|
| 282 |
+
state = build_empty_state()
|
| 283 |
+
|
| 284 |
+
update_setup_from_inputs(
|
| 285 |
+
state,
|
| 286 |
+
deck_title="URL Material Deck",
|
| 287 |
+
source_url="https://example.com/course-notes",
|
| 288 |
+
template_url="mock://template/course",
|
| 289 |
+
output_folder_id=None,
|
| 290 |
+
dry_run=True,
|
| 291 |
+
objectives_text="obj_1: Explain URL-backed material.",
|
| 292 |
+
)
|
| 293 |
+
result = grade_stage("setup_inputs", state)
|
| 294 |
+
|
| 295 |
+
assert result.passed_threshold
|
| 296 |
+
assert state.source_url == "https://example.com/course-notes"
|
| 297 |
+
assert state.source_chunks == {}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def test_setup_fails_without_upload_or_material_url():
|
| 301 |
+
state = build_empty_state()
|
| 302 |
+
|
| 303 |
+
update_setup_from_inputs(
|
| 304 |
+
state,
|
| 305 |
+
deck_title="Missing Material Deck",
|
| 306 |
+
source_url=None,
|
| 307 |
+
template_url="mock://template/course",
|
| 308 |
+
output_folder_id=None,
|
| 309 |
+
dry_run=True,
|
| 310 |
+
objectives_text="obj_1: Explain the material.",
|
| 311 |
+
)
|
| 312 |
+
result = grade_stage("setup_inputs", state)
|
| 313 |
+
|
| 314 |
+
assert not result.passed_threshold
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_imported_json_draft_creates_structured_candidate_artifacts(tmp_path):
|
| 318 |
+
draft_path = tmp_path / "draft.json"
|
| 319 |
+
draft_path.write_text(
|
| 320 |
+
json.dumps(
|
| 321 |
+
{
|
| 322 |
+
"objectives": {"obj_1": "Explain imported draft flow."},
|
| 323 |
+
"slides": [
|
| 324 |
+
{
|
| 325 |
+
"slide_id": "slide_1",
|
| 326 |
+
"slide_number": 1,
|
| 327 |
+
"title": "Imported Draft Slide",
|
| 328 |
+
"visible_text": "Imported draft text.",
|
| 329 |
+
"bullet_points": ["Review source", "Approve candidate"],
|
| 330 |
+
"objective_ids": ["obj_1"],
|
| 331 |
+
"pedagogical_role": "concept",
|
| 332 |
+
"speaker_notes": {
|
| 333 |
+
"slide_id": "slide_1",
|
| 334 |
+
"notes_text": "Imported notes for the instructor.",
|
| 335 |
+
},
|
| 336 |
+
}
|
| 337 |
+
],
|
| 338 |
+
"claims": [
|
| 339 |
+
{
|
| 340 |
+
"claim_id": "claim_1",
|
| 341 |
+
"slide_id": "slide_1",
|
| 342 |
+
"claim_text": "Imported draft text.",
|
| 343 |
+
"review_status": "unsupported",
|
| 344 |
+
}
|
| 345 |
+
],
|
| 346 |
+
"layout_specs": [
|
| 347 |
+
{
|
| 348 |
+
"slide_id": "slide_1",
|
| 349 |
+
"layout_id": "title_body",
|
| 350 |
+
"approved_template_id": "default_course_template",
|
| 351 |
+
"slot_assignments": {
|
| 352 |
+
"title": "Imported Draft Slide",
|
| 353 |
+
"body": "Imported draft text.",
|
| 354 |
+
},
|
| 355 |
+
}
|
| 356 |
+
],
|
| 357 |
+
"visual_assets": [],
|
| 358 |
+
}
|
| 359 |
+
),
|
| 360 |
+
encoding="utf-8",
|
| 361 |
+
)
|
| 362 |
+
state = build_empty_state()
|
| 363 |
+
|
| 364 |
+
update_setup_from_inputs(
|
| 365 |
+
state,
|
| 366 |
+
deck_title="Imported JSON Draft",
|
| 367 |
+
source_url="mock://source/imported",
|
| 368 |
+
template_url="mock://template/course",
|
| 369 |
+
output_folder_id=None,
|
| 370 |
+
dry_run=True,
|
| 371 |
+
objectives_text=None,
|
| 372 |
+
start_mode="import_existing_draft",
|
| 373 |
+
draft_file=str(draft_path),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
assert state.start_mode == "import_existing_draft"
|
| 377 |
+
assert state.slides["slide_1"].title == "Imported Draft Slide"
|
| 378 |
+
assert state.slides["slide_1"].speaker_notes.notes_text == "Imported notes for the instructor."
|
| 379 |
+
assert state.layout_specs["slide_1"].layout_id == "title_body"
|
| 380 |
+
assert state.claims["claim_1"].review_status == "unsupported"
|
| 381 |
+
assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE
|
| 382 |
+
assert get_current_stage_artifact("text_generation", state).status == ArtifactStatus.CANDIDATE
|
| 383 |
+
assert not has_valid_human_approval("slide_outline_order", state)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def test_imported_pptx_draft_creates_slide_records(tmp_path):
|
| 387 |
+
from pptx import Presentation
|
| 388 |
+
|
| 389 |
+
draft_path = tmp_path / "draft.pptx"
|
| 390 |
+
presentation = Presentation()
|
| 391 |
+
title_slide = presentation.slides.add_slide(presentation.slide_layouts[1])
|
| 392 |
+
title_slide.shapes.title.text = "PPTX Imported Slide"
|
| 393 |
+
title_slide.placeholders[1].text = "First bullet\nSecond bullet"
|
| 394 |
+
presentation.save(draft_path)
|
| 395 |
+
state = build_empty_state()
|
| 396 |
+
|
| 397 |
+
update_setup_from_inputs(
|
| 398 |
+
state,
|
| 399 |
+
deck_title="Imported PPTX Draft",
|
| 400 |
+
source_url="mock://source/imported",
|
| 401 |
+
template_url="mock://template/course",
|
| 402 |
+
output_folder_id=None,
|
| 403 |
+
dry_run=True,
|
| 404 |
+
objectives_text=None,
|
| 405 |
+
start_mode="import_existing_draft",
|
| 406 |
+
draft_file=str(draft_path),
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
assert state.slides["slide_1"].title == "PPTX Imported Slide"
|
| 410 |
+
assert "First bullet" in state.slides["slide_1"].visible_text
|
| 411 |
+
assert get_current_stage_artifact("slide_outline_order", state).status == ArtifactStatus.CANDIDATE
|