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

Add material upload and draft import setup

Browse files
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
- source_text: str,
 
 
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
- source_text=source_text,
 
 
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
- source_text,
 
 
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
- source_text,
 
 
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
- source_text,
 
 
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
- source_text,
 
 
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
- 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="")
@@ -235,10 +263,21 @@ def build_app() -> gr.Blocks:
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
 
@@ -392,7 +431,9 @@ def build_app() -> gr.Blocks:
392
  output_folder_id,
393
  dry_run,
394
  objectives_text,
395
- source_text,
 
 
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
- 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
@@ -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.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
  )
 
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
- if source_text and source_text.strip():
120
- state.source_chunks = {"chunk_1": source_text.strip()}
 
 
 
 
 
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