rigelbar's picture
Clarify setup candidate feedback
187b06a
Raw
History Blame Contribute Delete
62.6 kB
from __future__ import annotations
import json
import re
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree
from .constants import APPROVAL_REQUIRED_STAGE_IDS, STAGE_IDS, STAGE_LABELS, STAGE_SEQUENCE
from .compat import model_to_dict, model_validate
from .models import (
ArtifactStatus,
IssueStatus,
LayoutSpec,
PedagogicalRole,
PipelineState,
PromptRun,
ReviewRole,
RevisionConstraints,
RequestedChange,
ReviewerNotes,
Slide,
SlideClaim,
SpeakerNotes,
StageState,
VisualAsset,
)
from .quality import (
PASSING_SCORE,
approve_current_artifact,
can_unlock_next_stage,
compute_artifact_diff,
compute_deck_health_summary,
compute_objective_traces,
compute_slide_status,
create_artifact_version,
get_current_stage_artifact,
get_stage_lock_reasons,
grade_stage,
has_unresolved_blockers,
now_iso,
record_audit,
run_export_preflight,
stable_hash,
)
from .review import (
REVIEW_MODE_DEFAULTS,
apply_proposed_change_set,
assign_issue,
build_review_queue,
compare_artifact_versions_semantically,
compute_reviewer_productivity_metrics,
create_proposed_change_set,
create_review_packet,
critique_artifact_for_improvement,
generate_suggested_fix_for_issue,
generate_suggested_fixes,
improve_selected_slides,
list_prompt_runs_for_stage,
reject_proposed_change_set,
restore_artifact_version_as_candidate,
role_review_mode_summary,
update_issue_status,
)
TEXT_MATERIAL_EXTENSIONS = {".txt", ".md", ".csv"}
STRUCTURED_MATERIAL_EXTENSIONS = {".json"}
DOCUMENT_MATERIAL_EXTENSIONS = {".pdf", ".docx", ".pptx"}
IMAGE_DRAFT_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
START_MODES = {"instructions_file", "input_outline", "import_existing_draft", "start_from_zero"}
def build_empty_state(
*,
deck_title: str | None = None,
source_url: str | None = None,
template_url: str | None = None,
output_folder_id: str | None = None,
dry_run: bool = True,
mutation_target_url: str | None = None,
production_export_requested: bool = False,
) -> PipelineState:
job_seed = "|".join([deck_title or "course-deck", source_url or "mock://source"])
state = PipelineState(
job_id=f"job_{stable_hash(job_seed)[:10]}",
deck_title=deck_title,
source_url=source_url,
template_url=template_url,
output_folder_id=output_folder_id,
dry_run=dry_run,
mutation_target_url=mutation_target_url,
production_export_requested=production_export_requested,
stages={
stage_id: StageState(stage_id=stage_id, label=label)
for stage_id, label in STAGE_SEQUENCE
},
)
record_audit(state, "state_initialized", metadata={"dry_run": dry_run})
return state
def state_to_dict(state: PipelineState) -> dict[str, Any]:
return model_to_dict(state)
def state_from_dict(data: dict[str, Any] | PipelineState | None) -> PipelineState:
if isinstance(data, PipelineState):
return data
if not data:
return build_empty_state()
return model_validate(PipelineState, data)
def parse_objective_lines(objectives_text: str | None) -> dict[str, str]:
objectives: dict[str, str] = {}
for index, raw_line in enumerate((objectives_text or "").splitlines(), start=1):
line = raw_line.strip(" -\t")
if not line:
continue
if ":" in line and line.split(":", 1)[0].strip().lower().startswith("obj"):
objective_id, objective = line.split(":", 1)
objectives[objective_id.strip()] = objective.strip()
else:
objectives[f"obj_{index}"] = line
return objectives
def _normalize_file_paths(file_paths: Any) -> list[Path]:
if not file_paths:
return []
if isinstance(file_paths, str | Path):
return [Path(file_paths)]
normalized: list[Path] = []
for item in file_paths:
if isinstance(item, str | Path):
normalized.append(Path(item))
continue
path = getattr(item, "path", None) or getattr(item, "name", None)
if path:
normalized.append(Path(path))
return normalized
def _file_content_hash(path: Path) -> str:
return stable_hash({"path": path.name, "bytes": path.read_bytes().hex()})
def _read_text_file(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace").strip()
def _read_pdf_text(path: Path) -> str:
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
page_text: list[str] = []
for page in reader.pages:
try:
text = page.extract_text(extraction_mode="layout") or ""
except TypeError:
text = page.extract_text() or ""
page_text.append(text)
return "\n\n".join(page_text).strip()
except ImportError:
try:
import pdfplumber
except ImportError as exc:
raise RuntimeError("pypdf is required to parse PDF files.") from exc
with pdfplumber.open(str(path)) as pdf:
return "\n\n".join(page.extract_text() or "" for page in pdf.pages).strip()
def _read_docx_text(path: Path) -> str:
with zipfile.ZipFile(path) as archive:
document_xml = archive.read("word/document.xml")
root = ElementTree.fromstring(document_xml)
paragraph_tag = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p"
text_tag = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"
paragraphs: list[str] = []
for paragraph in root.iter(paragraph_tag):
text = "".join(node.text or "" for node in paragraph.iter(text_tag)).strip()
if text:
paragraphs.append(text)
return "\n".join(paragraphs).strip()
def _read_pptx_text(path: Path) -> str:
try:
from pptx import Presentation
except ImportError as exc:
raise RuntimeError("python-pptx is required to parse PPTX files.") from exc
presentation = Presentation(str(path))
slide_text: list[str] = []
for index, pptx_slide in enumerate(presentation.slides, start=1):
lines: list[str] = []
if pptx_slide.shapes.title and pptx_slide.shapes.title.has_text_frame:
title = pptx_slide.shapes.title.text.strip()
if title:
lines.append(title)
for shape in pptx_slide.shapes:
if getattr(shape, "has_text_frame", False):
text = shape.text.strip()
if text and text not in lines:
lines.append(text)
notes_text = _slide_notes_text(pptx_slide)
if notes_text:
lines.append(notes_text)
if lines:
slide_text.append(f"Slide {index}\n" + "\n".join(lines))
return "\n\n".join(slide_text).strip()
def _extract_document_text(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".pdf":
return _read_pdf_text(path)
if suffix == ".docx":
return _read_docx_text(path)
if suffix == ".pptx":
return _read_pptx_text(path)
return ""
def extract_uploaded_source_chunks(file_paths: Any) -> tuple[dict[str, str], list[dict[str, Any]], list[dict[str, Any]]]:
chunks: dict[str, str] = {}
parsed_materials: list[dict[str, Any]] = []
unsupported_materials: list[dict[str, Any]] = []
for index, path in enumerate(_normalize_file_paths(file_paths), start=1):
suffix = path.suffix.lower()
metadata = {
"filename": path.name,
"path": str(path),
"extension": suffix,
}
if suffix in TEXT_MATERIAL_EXTENSIONS:
text = _read_text_file(path)
if text:
chunk_id = f"upload_{index}"
chunks[chunk_id] = text
parsed_materials.append({**metadata, "chunk_id": chunk_id, "parsed": True})
continue
if suffix in STRUCTURED_MATERIAL_EXTENSIONS:
raw_text = _read_text_file(path)
if not raw_text:
continue
parsed = json.loads(raw_text)
chunk_id = f"upload_{index}"
chunks[chunk_id] = json.dumps(parsed, indent=2, sort_keys=True)
parsed_materials.append({**metadata, "chunk_id": chunk_id, "parsed": True})
continue
if suffix in DOCUMENT_MATERIAL_EXTENSIONS:
try:
text = _extract_document_text(path)
except Exception as exc: # noqa: BLE001 - user uploads should report parse failures, not crash setup.
unsupported_materials.append(
{
**metadata,
"parsed": False,
"reason": f"Could not parse {suffix} material: {exc}",
}
)
continue
if text:
chunk_id = f"upload_{index}"
chunks[chunk_id] = text
parsed_materials.append({**metadata, "chunk_id": chunk_id, "parsed": True})
continue
unsupported_materials.append(
{
**metadata,
"parsed": False,
"reason": f"No extractable text was found in {suffix} material.",
}
)
continue
unsupported_materials.append(
{
**metadata,
"parsed": False,
"reason": (
"Only .txt, .md, .csv, .json, .pdf, .docx, and .pptx material uploads "
"are parsed in this version."
),
}
)
return chunks, parsed_materials, unsupported_materials
def _normalize_start_mode(start_mode: str | None) -> str:
if start_mode in START_MODES:
return start_mode
return "instructions_file"
def _clean_outline_line(raw_line: str) -> str:
line = raw_line.strip()
line = re.sub(r"^#{1,6}\s*", "", line)
line = re.sub(r"^(?:[-*+]|\d+[.)]|[A-Za-z][.)]|[\u2022\u25cf\u25cb\u25a0])\s*", "", line)
return re.sub(r"\s+", " ", line).strip()
def _outline_line_level(raw_line: str) -> int:
stripped = raw_line.lstrip()
indent = len(raw_line) - len(stripped)
if stripped.startswith("\u25cf"):
return 0
if stripped.startswith(("\u25cb", "\u25a0")) or indent >= 2:
return 1
return 0
def _title_from_text(text: str, fallback: str) -> str:
title = re.sub(r"\s+", " ", text).strip(" .:-")
if not title:
return fallback
if len(title) <= 72:
return title
truncated = title[:69].rsplit(" ", 1)[0].rstrip(" .:-")
return f"{truncated or title[:69]}..."
def _bullet_from_text(text: str) -> str:
bullet = re.sub(r"\s+", " ", text).strip()
if len(bullet) <= 120:
return bullet
truncated = bullet[:117].rsplit(" ", 1)[0].rstrip(" .:-")
return f"{truncated or bullet[:117]}..."
def _sections_from_outline_text(outline_text: str, *, max_sections: int = 12) -> list[dict[str, Any]]:
sections: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for raw_line in outline_text.splitlines():
clean = _clean_outline_line(raw_line)
if not clean or clean.lower().startswith("slide "):
continue
level = _outline_line_level(raw_line)
if current is None or level == 0:
current = {"title": clean, "bullets": []}
sections.append(current)
if len(sections) >= max_sections:
break
continue
current["bullets"].append(clean)
return sections
def _role_for_section(title: str, index: int, total: int) -> PedagogicalRole:
lowered = title.lower()
if index == 1 or any(keyword in lowered for keyword in ["purpose", "intro", "motivat"]):
return PedagogicalRole.MOTIVATION
if any(keyword in lowered for keyword in ["process", "steps", "procedure", "compute", "calculate"]):
return PedagogicalRole.PROCEDURE
if any(keyword in lowered for keyword in ["example", "worked"]):
return PedagogicalRole.WORKED_EXAMPLE
if any(keyword in lowered for keyword in ["takeaway", "wrap", "summary"]):
return PedagogicalRole.SUMMARY
if index == total:
return PedagogicalRole.SUMMARY
return PedagogicalRole.CONCEPT
def _section_requires_visual(title: str, bullets: list[str]) -> bool:
text = " ".join([title, *bullets]).lower()
return any(
keyword in text
for keyword in [
"accuracy",
"chart",
"diagram",
"dimensionality",
"equation",
"math",
"matrix",
"model",
"process",
"sigmoid",
"visual",
]
)
def _build_slides_from_sections(sections: list[dict[str, Any]], objective_ids: list[str] | None = None) -> list[Slide]:
slides: list[Slide] = []
objective_ids = objective_ids or []
total = len(sections)
for index, section in enumerate(sections, start=1):
title = _title_from_text(str(section.get("title") or ""), f"Slide {index}")
bullets = [_bullet_from_text(str(item)) for item in section.get("bullets", []) if str(item).strip()]
bullets = [item for item in bullets if item][:5]
slide_id = f"slide_{index}"
mapped_objective_ids = [objective_ids[min(index - 1, len(objective_ids) - 1)]] if objective_ids else []
visible_text = "\n".join(bullets) if bullets else title
slides.append(
Slide(
slide_id=slide_id,
slide_number=index,
title=title,
visible_text=visible_text,
bullet_points=bullets,
objective_ids=mapped_objective_ids,
pedagogical_role=_role_for_section(title, index, total),
requires_visual=_section_requires_visual(title, bullets),
speaker_notes=SpeakerNotes(
slide_id=slide_id,
notes_text=f"Use the provided source material to teach: {title}.",
instructor_intent="Keep this slide source-grounded and concise.",
estimated_teaching_time_seconds=180,
),
distinct_concept_count=max(1, len(bullets) or 1),
)
)
return slides
def _derive_objectives_from_sections(sections: list[dict[str, Any]], *, max_objectives: int = 4) -> dict[str, str]:
objectives: dict[str, str] = {}
for index, section in enumerate(sections[:max_objectives], start=1):
title = _title_from_text(str(section.get("title") or ""), f"topic {index}")
objectives[f"obj_{index}"] = f"Explain {title[0].lower() + title[1:] if title else f'topic {index}'}."
return objectives
def _outline_text_from_state(state: PipelineState) -> str:
if state.start_mode == "input_outline":
return state.source_chunks.get("pasted_outline", "").strip()
if state.start_mode in {"instructions_file", "start_from_zero"}:
return "\n".join(state.source_chunks.values()).strip()
return ""
def _sequence_items(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, dict):
return list(value.values())
if isinstance(value, list):
return value
return []
def _import_json_draft(path: Path) -> dict[str, Any]:
data = json.loads(_read_text_file(path))
if not isinstance(data, dict):
raise ValueError("Draft JSON must be an object.")
objectives = data.get("objectives") or {}
source_chunks = data.get("source_chunks") or {}
slides = [
model_validate(Slide, item)
for item in _sequence_items(data.get("slides"))
]
claims = [
model_validate(SlideClaim, item)
for item in _sequence_items(data.get("claims"))
]
visual_assets = [
model_validate(VisualAsset, item)
for item in _sequence_items(data.get("visual_assets"))
]
layout_specs = [
model_validate(LayoutSpec, item)
for item in _sequence_items(data.get("layout_specs"))
]
return {
"objectives": objectives,
"source_chunks": source_chunks,
"slides": slides,
"claims": claims,
"visual_assets": visual_assets,
"layout_specs": layout_specs,
"metadata": {"format": "json", "filename": path.name},
}
def _slide_notes_text(slide: Any) -> str | None:
try:
notes_slide = slide.notes_slide
text_frame = getattr(notes_slide, "notes_text_frame", None)
if text_frame and getattr(text_frame, "text", None):
return text_frame.text.strip()
except (AttributeError, KeyError, ValueError):
return None
return None
def _import_pptx_draft(path: Path) -> dict[str, Any]:
try:
from pptx import Presentation
except ImportError as exc:
raise RuntimeError("python-pptx is required to import PPTX drafts.") from exc
presentation = Presentation(str(path))
slides: list[Slide] = []
source_lines: list[str] = []
for index, pptx_slide in enumerate(presentation.slides, start=1):
text_runs: list[str] = []
title = None
if pptx_slide.shapes.title and pptx_slide.shapes.title.has_text_frame:
title = pptx_slide.shapes.title.text.strip() or None
for shape in pptx_slide.shapes:
if not getattr(shape, "has_text_frame", False):
continue
text = shape.text.strip()
if text:
text_runs.append(text)
deduped_text = [text for text in text_runs if text != title]
bullet_points = []
for text in deduped_text:
bullet_points.extend([line.strip() for line in text.splitlines() if line.strip()])
slide_id = f"slide_{index}"
notes_text = _slide_notes_text(pptx_slide)
source_lines.append("\n".join([item for item in [title, *deduped_text, notes_text] if item]))
slides.append(
Slide(
slide_id=slide_id,
slide_number=index,
title=title or f"Imported slide {index}",
visible_text="\n".join(deduped_text),
bullet_points=bullet_points,
pedagogical_role=PedagogicalRole.UNKNOWN,
speaker_notes=(
SpeakerNotes(slide_id=slide_id, notes_text=notes_text)
if notes_text
else None
),
)
)
return {
"objectives": {},
"source_chunks": {"imported_pptx_text": "\n\n".join(item for item in source_lines if item)},
"slides": slides,
"claims": [],
"visual_assets": [],
"layout_specs": [],
"metadata": {"format": "pptx", "filename": path.name},
}
def _import_pdf_draft(path: Path) -> dict[str, Any]:
text = _read_pdf_text(path)
sections = _sections_from_outline_text(text)
objectives = _derive_objectives_from_sections(sections) if sections else {}
slides = _build_slides_from_sections(sections, list(objectives)) if sections else []
return {
"objectives": objectives,
"source_chunks": {"imported_pdf_text": text} if text else {},
"slides": slides,
"claims": [],
"visual_assets": [],
"layout_specs": [],
"metadata": {"format": "pdf", "filename": path.name},
}
def _import_image_draft(path: Path) -> dict[str, Any]:
slide_id = "slide_1"
title = _title_from_text(path.stem.replace("_", " ").replace("-", " "), "Uploaded draft image")
slide = Slide(
slide_id=slide_id,
slide_number=1,
title=title,
visible_text="Uploaded image draft. Add or revise slide text before final export.",
bullet_points=["Review image content", "Add source-grounded narration", "Confirm final layout"],
pedagogical_role=PedagogicalRole.UNKNOWN,
requires_visual=True,
speaker_notes=SpeakerNotes(
slide_id=slide_id,
notes_text="Use the uploaded image as the draft reference and refine text manually or with AI.",
instructor_intent="Preserve the user's visual draft while making the slide teachable.",
estimated_teaching_time_seconds=180,
),
)
visual_asset = VisualAsset(
asset_id="asset_1",
slide_id=slide_id,
asset_type="screenshot",
path_or_url=str(path),
purpose="instructional",
alt_text=f"Uploaded draft image for {title}",
source="uploaded",
license_status="user_provided",
approved_for_export=True,
)
layout = LayoutSpec(
slide_id=slide_id,
layout_id="title_bullets_visual",
approved_template_id="default_course_template",
slot_assignments={"title": title, "bullets": slide.bullet_points, "visual": visual_asset.asset_id},
)
return {
"objectives": {"obj_1": f"Explain {title[0].lower() + title[1:] if title else 'the uploaded draft'}."},
"source_chunks": {"uploaded_image_reference": f"Visual draft uploaded from {path.name}."},
"slides": [slide],
"claims": [],
"visual_assets": [visual_asset],
"layout_specs": [layout],
"metadata": {"format": "image", "filename": path.name},
}
def import_existing_draft(file_path: Any) -> dict[str, Any]:
paths = _normalize_file_paths(file_path)
if not paths:
return {}
path = paths[0]
suffix = path.suffix.lower()
if suffix == ".json":
return _import_json_draft(path)
if suffix == ".pptx":
return _import_pptx_draft(path)
if suffix == ".pdf":
return _import_pdf_draft(path)
if suffix in IMAGE_DRAFT_EXTENSIONS:
return _import_image_draft(path)
raise ValueError("Existing draft upload must be a .json, .pptx, .pdf, .png, .jpg, .jpeg, or .webp file.")
def _apply_imported_draft(state: PipelineState, draft: dict[str, Any], file_hash: str) -> None:
if draft.get("objectives"):
state.objectives = dict(draft["objectives"])
if draft.get("source_chunks"):
state.source_chunks.update(dict(draft["source_chunks"]))
for slide in draft.get("slides", []):
state.slides[slide.slide_id] = slide
for claim in draft.get("claims", []):
state.claims[claim.claim_id] = claim
for asset in draft.get("visual_assets", []):
state.visual_assets[asset.asset_id] = asset
for layout in draft.get("layout_specs", []):
state.layout_specs[layout.slide_id] = layout
imported_content = {
"slides": [model_to_dict(slide) for slide in draft.get("slides", [])],
"claims": [model_to_dict(claim) for claim in draft.get("claims", [])],
"visual_assets": [model_to_dict(asset) for asset in draft.get("visual_assets", [])],
"layout_specs": [model_to_dict(layout) for layout in draft.get("layout_specs", [])],
"metadata": draft.get("metadata", {}),
}
created_stage_ids: list[str] = []
if draft.get("objectives") or draft.get("source_chunks"):
create_artifact_version(
state,
"source_extraction_objective_mapping",
{
"objectives": state.objectives,
"source_chunks": state.source_chunks,
"import_metadata": draft.get("metadata", {}),
},
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
created_stage_ids.append("source_extraction_objective_mapping")
if draft.get("slides"):
create_artifact_version(
state,
"slide_outline_order",
imported_content,
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
create_artifact_version(
state,
"title_generation",
{"titles": {slide.slide_id: slide.title for slide in draft["slides"]}},
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
create_artifact_version(
state,
"text_generation",
imported_content,
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
created_stage_ids.extend(["slide_outline_order", "title_generation", "text_generation"])
if draft.get("visual_assets"):
create_artifact_version(
state,
"image_visual_asset_generation",
imported_content,
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
created_stage_ids.append("image_visual_asset_generation")
if draft.get("layout_specs"):
create_artifact_version(
state,
"aesthetic_ordering_visual_composition",
imported_content,
created_by="human",
status=ArtifactStatus.CANDIDATE,
mark_downstream_stale=False,
)
created_stage_ids.append("aesthetic_ordering_visual_composition")
state.draft_upload_metadata = {
**draft.get("metadata", {}),
"content_hash": file_hash,
"created_stage_ids": created_stage_ids,
"imported_at": now_iso(),
}
record_audit(
state,
"draft_imported",
metadata={
"filename": state.draft_upload_metadata.get("filename"),
"format": state.draft_upload_metadata.get("format"),
"created_stage_ids": created_stage_ids,
},
)
def apply_setup_material_inputs(
state: PipelineState,
*,
material_files: Any = None,
source_text: str | None = None,
outline_text: str | None = None,
start_mode: str = "instructions_file",
draft_file: Any = None,
) -> PipelineState:
state.start_mode = _normalize_start_mode(start_mode) # type: ignore[assignment]
if state.start_mode in {"instructions_file", "start_from_zero"}:
uploaded_chunks, parsed_materials, unsupported_materials = extract_uploaded_source_chunks(material_files)
state.source_chunks = uploaded_chunks
state.uploaded_materials = parsed_materials
state.unsupported_materials = unsupported_materials
if source_text and source_text.strip():
state.source_chunks = {"pasted_instructions": source_text.strip(), **state.source_chunks}
if state.start_mode == "input_outline":
state.uploaded_materials = []
state.unsupported_materials = []
source_chunks: dict[str, str] = {}
if outline_text and outline_text.strip():
source_chunks["pasted_outline"] = outline_text.strip()
if source_text and source_text.strip():
source_chunks["pasted_context"] = source_text.strip()
state.source_chunks = source_chunks
if state.start_mode == "import_existing_draft":
draft_paths = _normalize_file_paths(draft_file)
if draft_paths:
file_hash = _file_content_hash(draft_paths[0])
if state.draft_upload_metadata.get("content_hash") != file_hash:
draft = import_existing_draft(draft_paths[0])
_apply_imported_draft(state, draft, file_hash)
return state
def update_setup_from_inputs(
state: PipelineState,
*,
deck_title: str | None,
source_url: str | None,
template_url: str | None,
output_folder_id: str | None,
dry_run: bool,
objectives_text: str | None,
source_text: str | None = None,
outline_text: str | None = None,
material_files: Any = None,
start_mode: str = "instructions_file",
draft_file: Any = None,
mutation_target_url: str | None = None,
production_export_requested: bool = False,
) -> PipelineState:
state.deck_title = deck_title
state.source_url = source_url
state.template_url = template_url
state.output_folder_id = output_folder_id
state.dry_run = dry_run
state.mutation_target_url = mutation_target_url
state.production_export_requested = production_export_requested
parsed_objectives = parse_objective_lines(objectives_text)
if parsed_objectives:
state.objectives = parsed_objectives
apply_setup_material_inputs(
state,
material_files=material_files,
source_text=source_text,
outline_text=outline_text,
start_mode=start_mode,
draft_file=draft_file,
)
return state
def record_prompt_run(
state: PipelineState,
stage_id: str,
*,
rendered_prompt: str,
provider: str = "mock",
input_artifact_version_ids: list[str] | None = None,
) -> PromptRun:
prompt_run_id = f"prompt_{len(state.prompt_runs) + 1:05d}"
run = PromptRun(
prompt_run_id=prompt_run_id,
stage_id=stage_id,
provider=provider, # type: ignore[arg-type]
model_name="mock-deterministic-v1" if provider == "mock" else None,
prompt_template_id=f"{stage_id}_template",
prompt_template_version="p0.1",
rendered_prompt=rendered_prompt,
input_artifact_version_ids=input_artifact_version_ids or [],
settings={"temperature": 0, "dry_run": state.dry_run},
created_at=now_iso(),
)
state.prompt_runs[prompt_run_id] = run
record_audit(
state,
"prompt_run_recorded",
stage_id=stage_id,
metadata={"prompt_run_id": prompt_run_id, "provider": provider},
)
return run
def _ensure_default_objectives_and_source(state: PipelineState) -> None:
if not state.source_chunks:
state.source_chunks = {
"chunk_1": "Mock source chunk describing the key course ideas and examples."
}
outline_text = _outline_text_from_state(state)
if not state.objectives and outline_text:
sections = _sections_from_outline_text(outline_text)
if sections:
state.objectives = _derive_objectives_from_sections(sections)
if not state.objectives:
state.objectives = {
"obj_1": "Explain the core concept in plain language.",
"obj_2": "Apply the concept to a worked example.",
}
def _ensure_default_slides(state: PipelineState) -> None:
_ensure_default_objectives_and_source(state)
if state.slides:
return
outline_text = _outline_text_from_state(state)
if outline_text:
sections = _sections_from_outline_text(outline_text)
if sections:
slides = _build_slides_from_sections(sections, list(state.objectives))
state.slides = {slide.slide_id: slide for slide in slides}
return
slides: dict[str, Slide] = {}
for index, objective_id in enumerate(state.objectives, start=1):
role = PedagogicalRole.CONCEPT if index == 1 else PedagogicalRole.WORKED_EXAMPLE
if index == len(state.objectives) and len(state.objectives) > 2:
role = PedagogicalRole.SUMMARY
slide_id = f"slide_{index}"
slides[slide_id] = Slide(
slide_id=slide_id,
slide_number=index,
title=f"{state.objectives[objective_id].rstrip('.')}",
visible_text=f"This slide teaches {state.objectives[objective_id].lower()}",
bullet_points=["Key idea", "Example", "Takeaway"] if role != PedagogicalRole.CONCEPT else ["Key idea"],
objective_ids=[objective_id],
pedagogical_role=role,
requires_visual=index == 1,
speaker_notes=SpeakerNotes(
slide_id=slide_id,
notes_text=f"Guide learners through objective {objective_id} with one concrete example.",
instructor_intent="Keep the explanation concrete and source-grounded.",
estimated_teaching_time_seconds=180,
possible_student_confusions=["Students may overgeneralize the example."],
teaching_tips=["Ask learners to restate the idea before moving on."],
),
)
state.slides = slides
def _ensure_claims(state: PipelineState) -> None:
if state.claims:
return
for slide in state.slides.values():
claim_id = f"claim_{slide.slide_number}"
state.claims[claim_id] = SlideClaim(
claim_id=claim_id,
slide_id=slide.slide_id,
claim_text=slide.visible_text or slide.title or "Instructional claim",
source_ids=["source_1"],
source_chunk_ids=list(state.source_chunks)[:1],
review_status="supported",
)
record_audit(
state,
"claim_created",
slide_id=slide.slide_id,
claim_id=claim_id,
metadata={"review_status": "supported"},
)
def _ensure_visual_assets(state: PipelineState) -> None:
for slide in state.slides.values():
if not slide.requires_visual:
continue
if any(asset.slide_id == slide.slide_id for asset in state.visual_assets.values()):
continue
asset_id = f"asset_{slide.slide_number}"
state.visual_assets[asset_id] = VisualAsset(
asset_id=asset_id,
slide_id=slide.slide_id,
asset_type="diagram",
path_or_url=f"mock://assets/{asset_id}",
prompt=f"Instructional diagram for {slide.title}",
purpose="instructional",
alt_text=f"Diagram illustrating {slide.title}",
source="mock",
license_status="generated",
approved_for_export=True,
)
def _ensure_layouts(state: PipelineState) -> None:
for slide in state.slides.values():
if slide.slide_id in state.layout_specs:
continue
if slide.requires_visual:
layout_id = "title_bullets_visual"
slots = {
"title": slide.title or "",
"bullets": slide.bullet_points,
"visual": "primary_visual",
}
elif slide.pedagogical_role == PedagogicalRole.WORKED_EXAMPLE:
layout_id = "worked_example"
slots = {
"title": slide.title or "",
"problem": slide.visible_text,
"steps": slide.bullet_points,
}
else:
layout_id = "title_body"
slots = {"title": slide.title or "", "body": slide.visible_text}
state.layout_specs[slide.slide_id] = LayoutSpec(
slide_id=slide.slide_id,
layout_id=layout_id,
approved_template_id="default_course_template",
slot_assignments=slots,
)
def _material_snapshot(materials: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
key: material.get(key)
for key in ("filename", "extension", "chunk_id", "parsed", "reason")
if material.get(key) not in (None, "")
}
for material in materials
]
def _setup_snapshot_content(state: PipelineState) -> dict[str, Any]:
draft_summary = {
key: state.draft_upload_metadata.get(key)
for key in ("filename", "format", "content_hash", "created_stage_ids", "imported_at")
if state.draft_upload_metadata.get(key) not in (None, "")
}
return {
"deck_title": state.deck_title,
"start_mode": state.start_mode,
"source_url": state.source_url,
"template_url": state.template_url,
"dry_run": state.dry_run,
"production_export_requested": state.production_export_requested,
"output_folder_id": state.output_folder_id,
"mutation_target_url": state.mutation_target_url,
"input_summary": {
"source_chunk_count": len(state.source_chunks),
"uploaded_material_count": len(state.uploaded_materials),
"unsupported_material_count": len(state.unsupported_materials),
"objective_count": len(state.objectives),
"draft_imported": bool(state.draft_upload_metadata),
},
"uploaded_materials": _material_snapshot(state.uploaded_materials),
"unsupported_materials": _material_snapshot(state.unsupported_materials),
"draft_upload": draft_summary,
}
def generate_stage(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
if stage_id not in STAGE_IDS:
raise ValueError(f"Unknown stage: {stage_id}")
input_ids = [
artifact.artifact_version_id
for artifact in state.artifacts.values()
if artifact.stage_id in STAGE_IDS[: STAGE_IDS.index(stage_id)]
and artifact.is_current
]
prompt_run = record_prompt_run(
state,
stage_id,
rendered_prompt=f"Mock deterministic generation for {STAGE_LABELS[stage_id]}",
input_artifact_version_ids=input_ids,
)
if stage_id == "setup_inputs":
content = _setup_snapshot_content(state)
elif stage_id == "source_extraction_objective_mapping":
_ensure_default_objectives_and_source(state)
content = {"source_chunks": state.source_chunks, "objectives": state.objectives}
elif stage_id == "slide_outline_order":
_ensure_default_slides(state)
compute_objective_traces(state)
content = {"slides": [model_to_dict(slide) for slide in state.slides.values()]}
elif stage_id == "title_generation":
_ensure_default_slides(state)
for slide in state.slides.values():
slide.title = slide.title or f"Slide {slide.slide_number}"
content = {"titles": {slide.slide_id: slide.title for slide in state.slides.values()}}
elif stage_id == "text_generation":
_ensure_default_slides(state)
_ensure_claims(state)
content = {
"slides": [model_to_dict(slide) for slide in state.slides.values()],
"claims": [model_to_dict(claim) for claim in state.claims.values()],
}
elif stage_id == "image_visual_asset_generation":
_ensure_default_slides(state)
_ensure_visual_assets(state)
content = {"visual_assets": [model_to_dict(asset) for asset in state.visual_assets.values()]}
elif stage_id == "aesthetic_ordering_visual_composition":
_ensure_default_slides(state)
_ensure_layouts(state)
content = {"layout_specs": [model_to_dict(layout) for layout in state.layout_specs.values()]}
elif stage_id in {"technical_review", "pedagogical_review", "aesthetic_review"}:
content = {
"review_stage": stage_id,
"checked_at": now_iso(),
"slide_count": len(state.slides),
}
elif stage_id == "final_render_export":
report = run_export_preflight(state)
content = {"preflight": model_to_dict(report)}
else:
content = {
"audit_event_count": len(state.audit_events),
"artifact_count": len(state.artifacts),
}
artifact = create_artifact_version(
state,
stage_id,
content,
created_by="mock",
status=ArtifactStatus.CANDIDATE,
prompt_run_id=prompt_run.prompt_run_id,
)
prompt_run.output_artifact_version_id = artifact.artifact_version_id
prompt_run.artifact_version_id = artifact.artifact_version_id
return state, f"Generated {STAGE_LABELS[stage_id]} as {artifact.artifact_version_id}."
def grade_stage_action(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
result = grade_stage(stage_id, state)
for slide_id in state.slides:
compute_slide_status(slide_id, state)
generate_suggested_fixes(state)
compute_deck_health_summary(state)
return state, f"Grade for {STAGE_LABELS[stage_id]}: {result.score}."
def save_human_edits(
state: PipelineState,
stage_id: str,
edited_content: str,
reviewer_name: str,
reviewer_summary: str,
requested_changes_json: str,
) -> tuple[PipelineState, str]:
requested_changes: list[RequestedChange] = []
if requested_changes_json.strip():
parsed = json.loads(requested_changes_json)
if not isinstance(parsed, list):
raise ValueError("Requested changes JSON must be a list.")
requested_changes = [model_validate(RequestedChange, item) for item in parsed]
current = get_current_stage_artifact(stage_id, state)
notes = ReviewerNotes(
stage_id=stage_id,
artifact_version_id=current.artifact_version_id if current else None,
reviewer_name=reviewer_name or "human_reviewer",
summary=reviewer_summary or None,
requested_changes=requested_changes,
created_at=now_iso(),
)
state.reviewer_notes.append(notes)
record_audit(
state,
"reviewer_notes_saved",
stage_id=stage_id,
artifact_version_id=notes.artifact_version_id,
metadata={"requested_change_count": len(requested_changes)},
)
artifact = create_artifact_version(
state,
stage_id,
{"human_edit": edited_content, "reviewer_notes": model_to_dict(notes)},
created_by="human",
status=ArtifactStatus.CANDIDATE,
)
parent_id = artifact.parent_artifact_version_ids[0] if artifact.parent_artifact_version_ids else None
if parent_id:
compare_artifact_versions_semantically(parent_id, artifact.artifact_version_id, state)
generate_suggested_fixes(state)
return state, f"Saved human edits as candidate {artifact.artifact_version_id}."
def improve_with_ai(
state: PipelineState,
stage_id: str,
constraints: RevisionConstraints | None = None,
) -> tuple[PipelineState, str]:
current = get_current_stage_artifact(stage_id, state)
input_ids = [current.artifact_version_id] if current else []
prompt_run = record_prompt_run(
state,
stage_id,
rendered_prompt=f"Mock critique-before-improve for {STAGE_LABELS[stage_id]}",
input_artifact_version_ids=input_ids,
)
critique = critique_artifact_for_improvement(stage_id, state, constraints)
change_set = create_proposed_change_set(
stage_id,
state,
critique=critique,
constraints=constraints,
)
prompt_run.settings["critique_id"] = critique.critique_id
prompt_run.settings["change_set_id"] = change_set.change_set_id
return (
state,
"Created critique "
f"{critique.critique_id} and proposed change set {change_set.change_set_id}; "
"no artifact was mutated.",
)
def approve_and_continue(
state: PipelineState,
stage_id: str,
reviewer_name: str = "human_reviewer",
reviewer_role: ReviewRole | str | None = None,
) -> tuple[PipelineState, str]:
stage = state.stages[stage_id]
if stage.score is None or stage.score < 80:
return state, "Approval blocked: stage score is below 80 or missing."
if has_unresolved_blockers(stage_id, state):
return state, "Approval blocked: stage has unresolved blockers."
if stage.is_stale:
return state, "Approval blocked: stage is stale."
if get_current_stage_artifact(stage_id, state) is None:
return state, "Approval blocked: current artifact is missing."
role = ReviewRole(reviewer_role) if isinstance(reviewer_role, str) and reviewer_role else reviewer_role
approval = approve_current_artifact(state, stage_id, reviewer_name, role)
unlocks = can_unlock_next_stage(stage_id, state)
message = (
f"Approved {approval.artifact_version_id}; next stage can unlock."
if unlocks
else f"Approved {approval.artifact_version_id}; next stage remains locked."
)
return state, message
def run_preflight_action(state: PipelineState) -> tuple[PipelineState, str]:
report = run_export_preflight(state)
return state, report.summary
def final_render_export(state: PipelineState) -> tuple[PipelineState, str]:
report = run_export_preflight(state)
if not report.can_export:
record_audit(
state,
"export_blocked_by_preflight",
stage_id="final_render_export",
metadata={"blocking_issue_ids": report.blocking_issue_ids},
)
return state, report.summary
for stage_id in STAGE_IDS[:11]:
artifact = get_current_stage_artifact(stage_id, state)
if artifact and artifact.status == ArtifactStatus.APPROVED:
artifact.status = ArtifactStatus.EXPORTED
record_audit(
state,
"export_completed",
stage_id="final_render_export",
metadata={"dry_run": state.dry_run},
)
return state, "Dry-run export completed." if state.dry_run else "Production export completed."
def current_artifact_text(state: PipelineState, stage_id: str) -> str:
artifact = get_current_stage_artifact(stage_id, state)
if artifact is None:
return ""
return json.dumps(artifact.metadata.get("content", {}), indent=2, sort_keys=True)
def current_artifact_diff(state: PipelineState, stage_id: str) -> str:
versions = state.stage_artifact_versions.get(stage_id, [])
if len(versions) < 2:
return ""
previous = state.artifacts[versions[-2]].metadata.get("content", {})
current = state.artifacts[versions[-1]].metadata.get("content", {})
record_audit(state, "artifact_diff_viewed", stage_id=stage_id)
return compute_artifact_diff(previous, current)
def deck_health_table(state: PipelineState) -> list[list[Any]]:
summary = compute_deck_health_summary(state)
return [
["Job ID", summary.job_id],
["Deck title", summary.deck_title or ""],
["Approved stages", f"{summary.approved_stage_count}/{summary.total_stage_count}"],
["Stale stages", summary.stale_stage_count],
["Invalidated approvals", summary.invalidated_approval_count],
["Unresolved blockers", summary.unresolved_blocker_count],
["Unresolved major issues", summary.unresolved_major_issue_count],
["Slide count", summary.slide_count],
["Average slide score", summary.average_slide_score],
["Objectives strong", summary.objectives_strong],
["Objectives partial/weak", summary.objectives_partial_or_weak],
["Objectives uncovered", summary.objectives_uncovered],
["Unsupported claims", summary.unsupported_claim_count],
["Can export", summary.can_export],
["Top blockers", "; ".join(summary.top_blockers)],
]
def slide_inventory_table(state: PipelineState) -> list[list[Any]]:
for slide_id in state.slides:
compute_slide_status(slide_id, state)
rows: list[list[Any]] = []
for status in sorted(state.slide_statuses.values(), key=lambda item: item.slide_number):
rows.append(
[
status.slide_number,
status.title or "",
status.pedagogical_role.value,
", ".join(status.objective_ids),
", ".join(status.claim_ids),
", ".join(status.visual_asset_ids),
status.aggregate_score,
status.technical_score,
status.pedagogical_score,
status.aesthetic_score,
status.status,
len(status.issue_ids),
status.stale,
]
)
return rows
def objective_matrix_table(state: PipelineState) -> list[list[Any]]:
compute_objective_traces(state)
return [
[
trace.objective_id,
trace.objective_text,
", ".join(trace.mapped_slide_ids),
trace.coverage_score,
trace.coverage_status,
len(trace.evidence),
", ".join(trace.issue_ids),
]
for trace in state.objective_traces.values()
]
def stage_status_table(state: PipelineState) -> list[list[Any]]:
rows: list[list[Any]] = []
for stage_id in STAGE_IDS:
stage = state.stages[stage_id]
artifact = get_current_stage_artifact(stage_id, state)
rows.append(
[
STAGE_IDS.index(stage_id) + 1,
stage.label,
stage.score,
artifact.artifact_version_id if artifact else "",
artifact.status.value if artifact else "",
stage.is_stale,
can_unlock_next_stage(stage_id, state),
"; ".join(get_stage_lock_reasons(stage_id, state)),
]
)
return rows
def issue_table(state: PipelineState) -> list[list[Any]]:
return [
[
issue.issue_id,
issue.issue_type.value,
issue.severity.value,
issue.stage_id or "",
issue.slide_id or "",
issue.message,
issue.resolved,
]
for issue in state.issues.values()
]
def preflight_table(state: PipelineState) -> list[list[Any]]:
report = state.export_preflight_report
if report is None:
return []
return [
["can_export", report.can_export],
["checked_at", report.checked_at],
["blocking_issue_ids", "\n".join(report.blocking_issue_ids)],
["warning_issue_ids", "\n".join(report.warning_issue_ids)],
["summary", report.summary],
]
def audit_table(state: PipelineState) -> list[list[Any]]:
return [
[
event.event_id,
event.event_type,
event.timestamp,
event.stage_id or "",
event.slide_id or "",
event.issue_id or "",
event.reason or "",
]
for event in state.audit_events[-50:]
]
def review_role_choices() -> list[tuple[str, str]]:
return [(config.label, role.value) for role, config in REVIEW_MODE_DEFAULTS.items()]
def review_mode_summary_text(state: PipelineState, role: str | None) -> str:
return role_review_mode_summary(role, state)
def review_queue_table(
state: PipelineState,
role: str | None = None,
stage_id: str | None = None,
severity: str | None = None,
status: str | None = None,
issue_type: str | None = None,
assigned_to: str | None = None,
) -> list[list[Any]]:
queue = build_review_queue(
state,
role=role,
stage_id=stage_id,
severity=severity,
status=status,
issue_type=issue_type,
assigned_to=assigned_to or None,
)
return [
[
item.severity.value,
item.priority,
item.status.value,
item.stage_id or "",
item.slide_id or "",
item.assigned_role.value if item.assigned_role else "",
item.issue_type.value,
item.message,
item.suggested_fix_summary or "",
item.assigned_to or "",
item.created_at,
item.issue_id,
]
for item in queue
]
def suggested_fixes_table(state: PipelineState) -> list[list[Any]]:
if state.issues and not state.suggested_fixes:
generate_suggested_fixes(state)
return [
[
fix.fix_id,
fix.fix_type,
fix.stage_id or "",
", ".join(fix.slide_ids),
", ".join(fix.issue_ids),
fix.risk_level,
fix.description,
]
for fix in state.suggested_fixes.values()
]
def proposed_change_sets_table(state: PipelineState) -> list[list[Any]]:
return [
[
change_set.change_set_id,
change_set.stage_id,
change_set.status,
change_set.artifact_version_id or "",
len(change_set.changes),
change_set.summary or "",
change_set.created_at,
]
for change_set in state.proposed_change_sets.values()
]
def proposed_changes_table(state: PipelineState, change_set_id: str | None = None) -> list[list[Any]]:
change_sets = list(state.proposed_change_sets.values())
if change_set_id:
change_sets = [
change_set
for change_set in change_sets
if change_set.change_set_id == change_set_id
]
elif change_sets:
change_sets = [change_sets[-1]]
rows: list[list[Any]] = []
for change_set in change_sets:
for change in change_set.changes:
rows.append(
[
change_set.change_set_id,
change.change_id,
change.target_type,
change.target_id,
change.field_path or "",
json.dumps(change.before, sort_keys=True, default=str),
json.dumps(change.after, sort_keys=True, default=str),
change.rationale or "",
", ".join(change.issue_ids),
change.risk_level,
]
)
return rows
def latest_critique_text(state: PipelineState) -> str:
if not state.critiques:
return ""
critique = next(reversed(state.critiques.values()))
return json.dumps(model_to_dict(critique), indent=2, sort_keys=True)
def latest_semantic_diff_text(state: PipelineState) -> str:
if not state.version_comparisons:
return ""
comparison = next(reversed(state.version_comparisons.values()))
return json.dumps(model_to_dict(comparison), indent=2, sort_keys=True)
def prompt_runs_table(state: PipelineState, stage_id: str | None = "all") -> list[list[Any]]:
runs = list_prompt_runs_for_stage(stage_id or "all", state)
return [
[
run.prompt_run_id,
run.created_at,
run.stage_id,
run.provider,
run.model_name or "",
run.prompt_template_id or "",
", ".join(run.input_artifact_version_ids),
run.output_artifact_version_id or "",
json.dumps(run.settings, sort_keys=True),
]
for run in runs
]
def prompt_run_detail_text(state: PipelineState, prompt_run_id: str | None = None) -> str:
run = state.prompt_runs.get(prompt_run_id or "")
if run is None and state.prompt_runs:
run = next(reversed(state.prompt_runs.values()))
return json.dumps(model_to_dict(run), indent=2, sort_keys=True) if run else ""
def reviewer_metrics_table(state: PipelineState) -> list[list[Any]]:
metrics = compute_reviewer_productivity_metrics(state)
return [
["total_issues", metrics.total_issues],
["open_issues", metrics.open_issues],
["resolved_issues", metrics.resolved_issues],
["waived_issues", metrics.waived_issues],
["blocker_count", metrics.blocker_count],
["major_count", metrics.major_count],
["issues_by_stage", json.dumps(metrics.issues_by_stage, sort_keys=True)],
["issues_by_role", json.dumps(metrics.issues_by_role, sort_keys=True)],
["average_score_by_stage", json.dumps(metrics.average_score_by_stage, sort_keys=True)],
["candidate_versions_created", metrics.candidate_versions_created],
["approvals_invalidated", metrics.approvals_invalidated],
["stale_events", metrics.stale_events],
]
def triage_issue_action(
state: PipelineState,
issue_id: str,
action: str,
reviewer_name: str,
note: str,
assigned_role: str | None = None,
assigned_to: str | None = None,
) -> tuple[PipelineState, str]:
if not issue_id:
return state, "Select an issue ID first."
try:
if action == "assign":
assign_issue(issue_id, assigned_role, assigned_to, state)
return state, f"Assigned {issue_id}."
status_map = {
"acknowledge": IssueStatus.ACKNOWLEDGED,
"in_progress": IssueStatus.IN_PROGRESS,
"resolve": IssueStatus.RESOLVED,
"waive": IssueStatus.WAIVED,
"wont_fix": IssueStatus.WONT_FIX,
"reopen": IssueStatus.OPEN,
}
update_issue_status(
issue_id,
status_map[action],
state,
reviewer_name=reviewer_name or "human_reviewer",
note=note,
)
return state, f"Updated {issue_id} to {status_map[action].value}."
except (KeyError, ValueError) as exc:
return state, f"Issue triage failed: {exc}"
def generate_suggested_fix_action(state: PipelineState, issue_id: str) -> tuple[PipelineState, str]:
if not issue_id:
return state, "Select an issue ID first."
try:
fix = generate_suggested_fix_for_issue(issue_id, state)
return state, f"Generated suggested fix {fix.fix_id}."
except ValueError as exc:
return state, f"Suggested fix failed: {exc}"
def apply_change_set_action(
state: PipelineState,
change_set_id: str,
selected_change_ids_csv: str | None,
reviewer_name: str,
) -> tuple[PipelineState, str]:
if not change_set_id:
if not state.proposed_change_sets:
return state, "Create a proposed change set first."
change_set_id = next(reversed(state.proposed_change_sets))
selected = [
item.strip()
for item in (selected_change_ids_csv or "").split(",")
if item.strip()
]
try:
before_count = len(state.artifacts)
apply_proposed_change_set(
change_set_id,
state,
selected_change_ids=selected or None,
reviewer_name=reviewer_name or "human_reviewer",
)
after_count = len(state.artifacts)
message = (
f"Applied {change_set_id} and created a candidate artifact."
if after_count > before_count
else f"{change_set_id} was not applied."
)
return state, message
except ValueError as exc:
return state, f"Apply failed: {exc}"
def reject_change_set_action(
state: PipelineState,
change_set_id: str,
reviewer_name: str,
reason: str,
) -> tuple[PipelineState, str]:
if not change_set_id:
if not state.proposed_change_sets:
return state, "Create a proposed change set first."
change_set_id = next(reversed(state.proposed_change_sets))
try:
reject_proposed_change_set(
change_set_id,
state,
reviewer_name=reviewer_name or "human_reviewer",
reason=reason or None,
)
return state, f"Rejected {change_set_id}."
except ValueError as exc:
return state, f"Reject failed: {exc}"
def targeted_slide_improvement_action(
state: PipelineState,
stage_id: str,
slide_ids_csv: str,
action: str,
constraints: RevisionConstraints,
) -> tuple[PipelineState, str]:
slide_ids = [item.strip() for item in slide_ids_csv.split(",") if item.strip()]
if not slide_ids:
return state, "Enter one or more slide IDs."
change_set = improve_selected_slides(
stage_id,
slide_ids,
state,
constraints=constraints,
action=action, # type: ignore[arg-type]
)
return state, f"Created targeted change set {change_set.change_set_id}."
def compare_latest_versions_action(state: PipelineState, stage_id: str) -> tuple[PipelineState, str]:
versions = state.stage_artifact_versions.get(stage_id, [])
if len(versions) < 2:
return state, "At least two artifact versions are required for semantic comparison."
comparison = compare_artifact_versions_semantically(versions[-2], versions[-1], state)
return state, comparison.summary or f"Created comparison {comparison.comparison_id}."
def create_review_packet_action(
state: PipelineState,
role: str | None,
stage_ids_csv: str,
slide_ids_csv: str,
packet_format: str,
) -> tuple[PipelineState, str]:
stage_ids = [item.strip() for item in stage_ids_csv.split(",") if item.strip()]
slide_ids = [item.strip() for item in slide_ids_csv.split(",") if item.strip()]
packet = create_review_packet(
state,
role=role,
stage_ids=stage_ids,
slide_ids=slide_ids,
format="json" if packet_format == "json" else "markdown",
)
return state, f"Created {packet.format} review packet: {packet.path}"
def restore_candidate_action(
state: PipelineState,
artifact_version_id: str,
reviewer_name: str,
) -> tuple[PipelineState, str]:
if not artifact_version_id:
return state, "Enter an artifact version ID to restore."
try:
restore_artifact_version_as_candidate(
artifact_version_id,
state,
reviewer_name=reviewer_name or "human_reviewer",
)
return state, f"Restored {artifact_version_id} as a new candidate."
except ValueError as exc:
return state, f"Restore failed: {exc}"
def run_generate_and_grade_for_current_stage(
stage_id: str,
state: PipelineState,
) -> PipelineState:
record_audit(state, "fast_path_generate_grade_started", stage_id=stage_id)
generate_stage(state, stage_id)
grade_stage_action(state, stage_id)
stop_reason = None
if has_unresolved_blockers(stage_id, state):
stop_reason = "Stage has blockers."
elif state.stages[stage_id].score is not None and state.stages[stage_id].score < PASSING_SCORE:
stop_reason = "Stage score is below threshold."
elif stage_id in APPROVAL_REQUIRED_STAGE_IDS:
stop_reason = "Human approval is required."
record_audit(
state,
"fast_path_generate_grade_stopped",
stage_id=stage_id,
reason=stop_reason or "Current stage generated and graded.",
)
return state
def run_draft_until_next_required_approval(
start_stage_id: str,
state: PipelineState,
max_stages: int = 3,
) -> PipelineState:
if start_stage_id not in STAGE_IDS:
raise ValueError(f"Unknown stage: {start_stage_id}")
start_index = STAGE_IDS.index(start_stage_id)
for stage_id in STAGE_IDS[start_index : start_index + max_stages]:
run_generate_and_grade_for_current_stage(stage_id, state)
stage = state.stages[stage_id]
if has_unresolved_blockers(stage_id, state):
break
if stage.score is None or stage.score < PASSING_SCORE:
break
if stage_id in APPROVAL_REQUIRED_STAGE_IDS:
break
return state