rigelbar commited on
Commit
4b8ea27
·
1 Parent(s): 6d9bd01

Redesign workflow UI by stage

Browse files
Files changed (2) hide show
  1. app.py +910 -436
  2. tests/test_app_ui.py +56 -0
app.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  from typing import Any
5
 
6
  import gradio as gr
@@ -49,14 +50,185 @@ from course_slide_factory.workflow import (
49
  update_setup_from_inputs,
50
  approve_and_continue,
51
  )
52
- from course_slide_factory.quality import get_stage_lock_reasons
53
- from course_slide_factory.models import IssueSeverity, IssueStatus, IssueType, RevisionConstraints
54
 
55
 
56
  CSS = """
57
- .gradio-container { max-width: 1480px !important; }
58
  textarea, .cm-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
59
  .compact-panel table { font-size: 12px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  """
61
 
62
  START_PATH_SUMMARIES = {
@@ -74,6 +246,277 @@ START_PATH_SUMMARIES = {
74
  ),
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  def _has_file(file_value) -> bool:
79
  if not file_value:
@@ -178,6 +621,7 @@ def _render_outputs(
178
  change_set_id: str = "",
179
  prompt_run_id: str = "",
180
  ):
 
181
  lock_reasons = "\n".join(get_stage_lock_reasons(stage_id, state)) or "Unlocked"
182
  role_summary = review_mode_summary_text(state, review_role)
183
  review_queue = review_queue_table(
@@ -222,6 +666,7 @@ def _render_outputs(
222
  prompt_runs,
223
  prompt_detail,
224
  metrics,
 
225
  )
226
 
227
 
@@ -355,9 +800,22 @@ def handle_export(state_data, stage_id):
355
  return _render_outputs(state, stage_id, message)
356
 
357
 
358
- def handle_stage_change(state_data, stage_id):
 
359
  state = state_from_dict(state_data)
360
- return _render_outputs(state, stage_id, f"Selected {STAGE_LABELS[stage_id]}.")
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
 
363
  def _constraints_from_inputs(
@@ -644,377 +1102,393 @@ def build_app() -> gr.Blocks:
644
  ("Suggest layout switch", "suggest_layout_switch"),
645
  ("Add alt text", "add_alt_text"),
646
  ]
 
 
647
  with gr.Blocks(title="Course Slide Factory") as demo:
648
  state_store = gr.State()
649
-
650
- gr.Markdown("## Course Slide Factory")
651
- with gr.Row():
652
- with gr.Column(scale=1, min_width=320):
653
- deck_title = gr.Textbox(label="Deck title", value="Course Slide Deck")
654
- gr.Markdown("### Start path")
655
- with gr.Row():
656
- instructions_path = gr.Button("Instructions file")
657
- outline_path = gr.Button("Input outline")
658
- draft_path = gr.Button("Draft deck")
659
- start_mode = gr.Textbox(value="instructions_file", visible=False)
660
- selected_start_path = gr.Markdown(START_PATH_SUMMARIES["instructions_file"])
661
- with gr.Group(visible=True) as instructions_panel:
662
- material_files = gr.File(
663
- label="Instructions file",
664
- file_count="multiple",
665
- file_types=[".txt", ".md", ".csv", ".json", ".pdf", ".docx", ".pptx"],
666
- type="filepath",
667
- )
668
- source_url = gr.Textbox(label="Material URL", value="")
669
- with gr.Group(visible=False) as outline_panel:
670
- outline_text = gr.Textbox(
671
- label="Outline",
672
- lines=8,
673
- placeholder="Opening hook\nCore concept\nWorked example\nPractice prompt\nWrap-up",
674
- )
675
- source_text = gr.Textbox(
676
- label="Input information",
677
- lines=6,
678
- placeholder="Audience, prerequisites, source notes, constraints, examples, or must-cover points.",
679
  )
680
- with gr.Group(visible=False) as draft_panel:
681
- draft_file = gr.File(
682
- label="Draft input required (.pptx, .pdf, or image)",
683
- file_count="single",
684
- file_types=[".pptx", ".pdf", ".png", ".jpg", ".jpeg", ".webp"],
685
- type="filepath",
686
- )
687
- template_url = gr.Textbox(label="Template URL", value="mock://template/course")
688
- output_folder_id = gr.Textbox(label="Output folder ID", value="")
689
- mutation_target_url = gr.Textbox(label="Mutation target URL", value="")
690
- with gr.Row():
691
- dry_run = gr.Checkbox(label="Dry run", value=True)
692
- production_export_requested = gr.Checkbox(label="Production export", value=False)
693
- objectives_text = gr.Textbox(
694
- label="Learning objectives",
695
- lines=5,
696
- placeholder="obj_1: Explain the core course concept.\nobj_2: Apply the concept to a worked example.",
697
- value="",
698
- )
699
- update_setup = gr.Button("Save Setup", variant="secondary")
700
 
 
701
  with gr.Column(scale=2):
702
- stage_id = gr.Dropdown(
703
- label="Workflow stage",
704
- choices=stage_choices,
705
- value=STAGE_IDS[0],
706
- interactive=True,
707
- )
708
- with gr.Row():
709
- generate = gr.Button("Generate", variant="primary")
710
- grade = gr.Button("Grade")
711
- improve = gr.Button("Improve with AI")
712
- approve = gr.Button("Approve & Continue", variant="primary")
713
- with gr.Row():
714
- preflight = gr.Button("Run Preflight")
715
- export = gr.Button("Final Render & Export", variant="stop")
716
  status = gr.Markdown()
717
- lock_reasons = gr.Textbox(label="Lock reasons", lines=4, interactive=False)
718
-
719
- with gr.Tabs():
720
- with gr.Tab("Artifact"):
721
- artifact_editor = gr.Textbox(
722
- label="Current artifact / human edit",
723
- lines=16,
724
- interactive=True,
725
- )
726
- with gr.Row():
727
- reviewer_name = gr.Textbox(label="Reviewer", value="human_reviewer")
728
- reviewer_summary = gr.Textbox(label="Reviewer summary")
729
- requested_changes_json = gr.Textbox(
730
- label="Requested changes JSON",
731
- lines=6,
732
- value="[]",
733
- )
734
- save_edits = gr.Button("Save Human Edits")
735
- artifact_diff = gr.Textbox(label="Artifact diff", lines=12, interactive=False)
736
- with gr.Tab("Deck Health"):
737
- deck_health = gr.Dataframe(
738
- headers=["Metric", "Value"],
739
- interactive=False,
740
- elem_classes=["compact-panel"],
741
- )
742
- with gr.Tab("Slide Inventory"):
743
- slide_inventory = gr.Dataframe(
744
- headers=[
745
- "Slide #",
746
- "Title",
747
- "Role",
748
- "Objectives",
749
- "Claims",
750
- "Visuals",
751
- "Aggregate",
752
- "Technical",
753
- "Pedagogical",
754
- "Aesthetic",
755
- "Status",
756
- "Issues",
757
- "Stale?",
758
- ],
759
- interactive=False,
760
- elem_classes=["compact-panel"],
761
- )
762
- with gr.Tab("Objectives"):
763
- objective_matrix = gr.Dataframe(
764
- headers=[
765
- "Objective ID",
766
- "Objective",
767
- "Mapped Slides",
768
- "Coverage Score",
769
- "Coverage Status",
770
- "Evidence Count",
771
- "Issues",
772
- ],
773
- interactive=False,
774
- elem_classes=["compact-panel"],
775
- )
776
- with gr.Tab("Preflight"):
777
- preflight_results = gr.Dataframe(
778
- headers=["Field", "Value"],
779
- interactive=False,
780
- elem_classes=["compact-panel"],
781
- )
782
- with gr.Tab("Stages"):
783
- stage_status = gr.Dataframe(
784
- headers=[
785
- "#",
786
- "Stage",
787
- "Score",
788
- "Artifact",
789
- "Artifact Status",
790
- "Stale?",
791
- "Can Unlock Next?",
792
- "Lock Reasons",
793
- ],
794
- interactive=False,
795
- elem_classes=["compact-panel"],
796
- )
797
- with gr.Tab("Issues"):
798
- issues = gr.Dataframe(
799
- headers=[
800
- "Issue ID",
801
- "Type",
802
- "Severity",
803
- "Stage",
804
- "Slide",
805
- "Message",
806
- "Resolved?",
807
- ],
808
- interactive=False,
809
- elem_classes=["compact-panel"],
810
- )
811
- with gr.Tab("Review Workbench"):
812
- with gr.Row():
813
- review_role = gr.Dropdown(
814
- label="Review role",
815
- choices=review_role_options,
816
- value="general_reviewer",
817
- interactive=True,
818
- )
819
- queue_stage = gr.Dropdown(
820
- label="Stage filter",
821
- choices=queue_stage_choices,
822
- value="all",
823
- interactive=True,
824
- )
825
- queue_severity = gr.Dropdown(
826
- label="Severity filter",
827
- choices=severity_choices,
828
- value="all",
829
- interactive=True,
830
- )
831
- with gr.Row():
832
- queue_status = gr.Dropdown(
833
- label="Status filter",
834
- choices=status_choices,
835
- value="all",
836
- interactive=True,
837
- )
838
- queue_issue_type = gr.Dropdown(
839
- label="Issue type filter",
840
- choices=issue_type_choices,
841
- value="all",
842
- interactive=True,
843
- )
844
- assigned_filter = gr.Textbox(label="Assigned reviewer filter")
845
- role_summary = gr.Textbox(label="Review mode", lines=3, interactive=False)
846
- refresh_review = gr.Button("Refresh Review Workbench")
847
- review_queue = gr.Dataframe(
848
- headers=[
849
- "Severity",
850
- "Priority",
851
- "Status",
852
- "Stage",
853
- "Slide",
854
- "Role",
855
- "Issue Type",
856
- "Message",
857
- "Suggested Fix",
858
- "Assigned To",
859
- "Created At",
860
- "Issue ID",
861
- ],
862
- interactive=False,
863
- elem_classes=["compact-panel"],
864
- )
865
- with gr.Row():
866
- selected_issue_id = gr.Textbox(label="Issue ID")
867
- issue_action = gr.Dropdown(
868
- label="Triage action",
869
- choices=[
870
- ("Acknowledge", "acknowledge"),
871
- ("Mark in progress", "in_progress"),
872
- ("Resolve", "resolve"),
873
- ("Waive", "waive"),
874
- ("Won't fix", "wont_fix"),
875
- ("Reopen", "reopen"),
876
- ("Assign", "assign"),
877
- ],
878
- value="acknowledge",
879
- )
880
- triage_note = gr.Textbox(label="Reason or note")
881
- with gr.Row():
882
- assigned_role = gr.Dropdown(
883
- label="Assign role",
884
- choices=[("No role", "all"), *review_role_options],
885
- value="all",
886
- )
887
- assigned_to = gr.Textbox(label="Assign reviewer")
888
- triage_issue = gr.Button("Apply Triage")
889
- generate_issue_fix = gr.Button("Generate Suggested Fix")
890
- suggested_fixes = gr.Dataframe(
891
- headers=[
892
- "Fix ID",
893
- "Type",
894
- "Stage",
895
- "Slides",
896
- "Issues",
897
- "Risk",
898
- "Description",
899
- ],
900
- interactive=False,
901
- elem_classes=["compact-panel"],
902
- )
903
- with gr.Row():
904
- fast_generate_grade = gr.Button("Generate + Grade Current Stage")
905
- fast_draft_ahead = gr.Button("Draft Ahead Until Review Needed")
906
- with gr.Row():
907
- preserve_slide_order = gr.Checkbox(label="Preserve slide order", value=True)
908
- preserve_slide_titles = gr.Checkbox(label="Preserve slide titles", value=False)
909
- preserve_technical_claims = gr.Checkbox(label="Preserve technical claims", value=True)
910
- with gr.Row():
911
- preserve_objective_mappings = gr.Checkbox(
912
- label="Preserve objective mappings",
913
- value=True,
914
- )
915
- preserve_visual_assets = gr.Checkbox(label="Preserve visual assets", value=False)
916
- preserve_layout_ids = gr.Checkbox(label="Preserve layout IDs", value=False)
917
- with gr.Row():
918
- constraint_slide_ids = gr.Textbox(label="Selected slide IDs")
919
- allowed_fields = gr.Textbox(label="Allowed fields")
920
- blocked_fields = gr.Textbox(label="Blocked fields")
921
- reviewer_instruction = gr.Textbox(label="Reviewer instruction", lines=2)
922
- with gr.Row():
923
- propose_with_constraints = gr.Button("Critique + Propose Changes")
924
- targeted_slide_ids = gr.Textbox(label="Target slide IDs")
925
- targeted_action = gr.Dropdown(
926
- label="Selected slide action",
927
- choices=targeted_action_choices,
928
- value="rewrite_visible_text",
929
- )
930
- targeted_improvement = gr.Button("Create Targeted Changes")
931
- critique_output = gr.Textbox(label="Latest critique", lines=8, interactive=False)
932
- proposed_change_sets = gr.Dataframe(
933
- headers=[
934
- "Change Set ID",
935
- "Stage",
936
- "Status",
937
- "Artifact",
938
- "Changes",
939
- "Summary",
940
- "Created At",
941
- ],
942
- interactive=False,
943
- elem_classes=["compact-panel"],
944
- )
945
- with gr.Row():
946
- selected_change_set_id = gr.Textbox(label="Change set ID")
947
- selected_change_ids = gr.Textbox(label="Selected change IDs")
948
- apply_changes = gr.Button("Apply Selected Proposed Changes", variant="primary")
949
- reject_changes = gr.Button("Reject Change Set")
950
- proposed_changes = gr.Dataframe(
951
- headers=[
952
- "Change Set",
953
- "Change ID",
954
- "Target Type",
955
- "Target ID",
956
- "Field",
957
- "Before",
958
- "After",
959
- "Rationale",
960
- "Issues",
961
- "Risk",
962
- ],
963
- interactive=False,
964
- elem_classes=["compact-panel"],
965
  )
966
- semantic_compare = gr.Button("Compare Latest Versions")
967
- semantic_diff = gr.Textbox(label="Semantic diff", lines=10, interactive=False)
968
- with gr.Row():
969
- packet_stage_ids = gr.Textbox(label="Packet stage IDs")
970
- packet_slide_ids = gr.Textbox(label="Packet slide IDs")
971
- packet_format = gr.Radio(
972
- label="Packet format",
973
- choices=[("Markdown", "markdown"), ("JSON", "json")],
974
- value="markdown",
975
- )
976
- create_packet = gr.Button("Create Review Packet")
977
- with gr.Row():
978
- restore_artifact_id = gr.Textbox(label="Artifact version ID")
979
- restore_candidate = gr.Button("Restore as Candidate")
980
- prompt_runs = gr.Dataframe(
981
- headers=[
982
- "Prompt Run ID",
983
- "Created At",
984
- "Stage",
985
- "Provider",
986
- "Model",
987
- "Template",
988
- "Inputs",
989
- "Output",
990
- "Settings",
991
- ],
992
- interactive=False,
993
- elem_classes=["compact-panel"],
994
  )
995
- prompt_run_id = gr.Textbox(label="Prompt run ID")
996
- prompt_detail = gr.Textbox(label="Prompt run detail", lines=8, interactive=False)
997
- reviewer_metrics = gr.Dataframe(
998
- headers=["Metric", "Value"],
999
- interactive=False,
1000
- elem_classes=["compact-panel"],
1001
  )
1002
- with gr.Tab("Audit"):
1003
- audit = gr.Dataframe(
1004
- headers=[
1005
- "Event ID",
1006
- "Type",
1007
- "Timestamp",
1008
- "Stage",
1009
- "Slide",
1010
- "Issue",
1011
- "Reason",
1012
- ],
1013
- interactive=False,
1014
- elem_classes=["compact-panel"],
1015
  )
1016
- with gr.Tab("State JSON"):
1017
- state_json = gr.Textbox(label="State", lines=18, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
 
1019
  outputs = [
1020
  state_store,
@@ -1040,6 +1514,15 @@ def build_app() -> gr.Blocks:
1040
  prompt_runs,
1041
  prompt_detail,
1042
  reviewer_metrics,
 
 
 
 
 
 
 
 
 
1043
  ]
1044
  setup_inputs = [
1045
  state_store,
@@ -1113,44 +1596,62 @@ def build_app() -> gr.Blocks:
1113
  blocked_fields,
1114
  reviewer_instruction,
1115
  ]
1116
- start_path_outputs = [
1117
- start_mode,
1118
- selected_start_path,
1119
- instructions_panel,
1120
- outline_panel,
1121
- draft_panel,
1122
- ]
1123
 
1124
- demo.load(
1125
- handle_update_setup,
1126
- inputs=setup_inputs,
1127
- outputs=outputs,
1128
- show_progress="hidden",
1129
- )
 
1130
  instructions_path.click(select_instructions_path, inputs=[], outputs=start_path_outputs)
1131
  outline_path.click(select_outline_path, inputs=[], outputs=start_path_outputs)
1132
  draft_path.click(select_draft_path, inputs=[], outputs=start_path_outputs)
 
 
 
 
1133
  update_setup.click(handle_update_setup, inputs=setup_inputs, outputs=outputs)
1134
- generate.click(handle_generate, inputs=setup_inputs, outputs=outputs)
1135
- grade.click(handle_grade, inputs=[state_store, stage_id], outputs=outputs)
1136
- save_edits.click(
1137
- handle_save_edits,
1138
- inputs=[
1139
- state_store,
1140
- stage_id,
1141
- artifact_editor,
1142
- reviewer_name,
1143
- reviewer_summary,
1144
- requested_changes_json,
1145
- ],
1146
- outputs=outputs,
1147
- )
1148
- improve.click(handle_improve, inputs=[state_store, stage_id], outputs=outputs)
1149
- approve.click(handle_approve, inputs=[state_store, stage_id, reviewer_name, review_role], outputs=outputs)
1150
- preflight.click(handle_preflight, inputs=[state_store, stage_id], outputs=outputs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  export.click(handle_export, inputs=[state_store, stage_id], outputs=outputs)
1152
- stage_id.change(handle_stage_change, inputs=[state_store, stage_id], outputs=outputs)
1153
- refresh_review.click(handle_review_refresh, inputs=review_filter_inputs, outputs=outputs)
 
1154
  for filter_component in [
1155
  review_role,
1156
  queue_stage,
@@ -1161,28 +1662,15 @@ def build_app() -> gr.Blocks:
1161
  selected_change_set_id,
1162
  prompt_run_id,
1163
  ]:
1164
- filter_component.change(
1165
- handle_review_refresh,
1166
- inputs=review_filter_inputs,
1167
- outputs=outputs,
1168
- show_progress="hidden",
1169
- )
1170
  triage_issue.click(handle_triage_issue, inputs=triage_inputs, outputs=outputs)
1171
- generate_issue_fix.click(handle_suggested_fix, inputs=suggested_fix_inputs, outputs=outputs)
1172
- propose_with_constraints.click(
1173
- handle_propose_with_constraints,
1174
- inputs=[state_store, stage_id, *constraint_inputs],
1175
- outputs=outputs,
1176
- )
1177
  targeted_improvement.click(
1178
  handle_targeted_improvement,
1179
- inputs=[
1180
- state_store,
1181
- stage_id,
1182
- targeted_slide_ids,
1183
- targeted_action,
1184
- *constraint_inputs,
1185
- ],
1186
  outputs=outputs,
1187
  )
1188
  apply_changes.click(
@@ -1195,27 +1683,13 @@ def build_app() -> gr.Blocks:
1195
  inputs=[state_store, stage_id, selected_change_set_id, reviewer_name, triage_note],
1196
  outputs=outputs,
1197
  )
1198
- fast_generate_grade.click(
1199
- handle_fast_generate_grade,
1200
- inputs=[state_store, stage_id],
1201
- outputs=outputs,
1202
- )
1203
- fast_draft_ahead.click(
1204
- handle_fast_draft_ahead,
1205
- inputs=[state_store, stage_id],
1206
- outputs=outputs,
1207
- )
1208
- semantic_compare.click(handle_semantic_compare, inputs=[state_store, stage_id], outputs=outputs)
1209
  create_packet.click(
1210
  handle_review_packet,
1211
- inputs=[
1212
- state_store,
1213
- stage_id,
1214
- review_role,
1215
- packet_stage_ids,
1216
- packet_slide_ids,
1217
- packet_format,
1218
- ],
1219
  outputs=outputs,
1220
  )
1221
  restore_candidate.click(
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import html
5
  from typing import Any
6
 
7
  import gradio as gr
 
50
  update_setup_from_inputs,
51
  approve_and_continue,
52
  )
53
+ from course_slide_factory.quality import can_unlock_next_stage, get_current_stage_artifact, get_stage_lock_reasons
54
+ from course_slide_factory.models import ArtifactStatus, IssueSeverity, IssueStatus, IssueType, RevisionConstraints
55
 
56
 
57
  CSS = """
58
+ .gradio-container { max-width: 1540px !important; background: #f7f8fa; }
59
  textarea, .cm-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
60
  .compact-panel table { font-size: 12px; }
61
+ .workflow-topbar {
62
+ position: sticky;
63
+ top: 0;
64
+ z-index: 20;
65
+ background: rgba(247, 248, 250, 0.96);
66
+ border-bottom: 1px solid #d9dee7;
67
+ padding: 10px 0 12px;
68
+ backdrop-filter: blur(8px);
69
+ }
70
+ .workflow-shell {
71
+ border: 1px solid #d9dee7;
72
+ background: #ffffff;
73
+ border-radius: 8px;
74
+ padding: 14px 16px;
75
+ }
76
+ .workflow-title-row {
77
+ display: flex;
78
+ align-items: flex-start;
79
+ justify-content: space-between;
80
+ gap: 18px;
81
+ }
82
+ .workflow-kicker {
83
+ color: #667085;
84
+ font-size: 11px;
85
+ font-weight: 700;
86
+ letter-spacing: 0.06em;
87
+ text-transform: uppercase;
88
+ }
89
+ .workflow-shell h2 {
90
+ color: #111827;
91
+ font-size: 22px;
92
+ line-height: 1.2;
93
+ margin: 4px 0 2px;
94
+ }
95
+ .workflow-shell p {
96
+ color: #4b5563;
97
+ margin: 0;
98
+ }
99
+ .workflow-progress-card {
100
+ min-width: 170px;
101
+ border: 1px solid #d9dee7;
102
+ border-radius: 8px;
103
+ padding: 10px 12px;
104
+ text-align: right;
105
+ background: #f9fafb;
106
+ }
107
+ .workflow-progress-card strong {
108
+ display: block;
109
+ color: #111827;
110
+ font-size: 26px;
111
+ line-height: 1;
112
+ }
113
+ .workflow-progress-card span {
114
+ color: #667085;
115
+ font-size: 12px;
116
+ }
117
+ .workflow-progress-track {
118
+ height: 8px;
119
+ background: #e5e7eb;
120
+ border-radius: 999px;
121
+ margin-top: 12px;
122
+ overflow: hidden;
123
+ }
124
+ .workflow-progress-fill {
125
+ height: 100%;
126
+ background: #2563eb;
127
+ border-radius: inherit;
128
+ }
129
+ .workflow-metrics {
130
+ display: grid;
131
+ gap: 8px;
132
+ grid-template-columns: repeat(4, minmax(0, 1fr));
133
+ margin-top: 12px;
134
+ }
135
+ .workflow-metric {
136
+ border: 1px solid #e5e7eb;
137
+ border-radius: 8px;
138
+ padding: 8px 10px;
139
+ background: #ffffff;
140
+ }
141
+ .workflow-metric span {
142
+ color: #667085;
143
+ display: block;
144
+ font-size: 11px;
145
+ }
146
+ .workflow-metric strong {
147
+ color: #111827;
148
+ display: block;
149
+ font-size: 15px;
150
+ margin-top: 2px;
151
+ }
152
+ .stage-score-grid {
153
+ display: grid;
154
+ gap: 6px;
155
+ grid-template-columns: repeat(auto-fit, minmax(126px, 1fr));
156
+ }
157
+ .stage-score-chip {
158
+ border: 1px solid #d9dee7;
159
+ border-left: 4px solid #9ca3af;
160
+ border-radius: 8px;
161
+ background: #ffffff;
162
+ padding: 8px;
163
+ min-height: 74px;
164
+ }
165
+ .stage-score-chip.active {
166
+ border-color: #2563eb;
167
+ border-left-color: #2563eb;
168
+ box-shadow: inset 0 0 0 1px #2563eb;
169
+ }
170
+ .stage-score-chip.complete { border-left-color: #059669; }
171
+ .stage-score-chip.review { border-left-color: #d97706; }
172
+ .stage-score-chip.blocked { border-left-color: #dc2626; }
173
+ .stage-score-chip.stale { border-left-color: #7c3aed; }
174
+ .stage-score-chip.ready { border-left-color: #2563eb; }
175
+ .stage-chip-top {
176
+ align-items: center;
177
+ display: flex;
178
+ gap: 6px;
179
+ justify-content: space-between;
180
+ }
181
+ .stage-chip-number,
182
+ .stage-chip-score {
183
+ color: #111827;
184
+ font-size: 12px;
185
+ font-weight: 700;
186
+ }
187
+ .stage-chip-label {
188
+ color: #111827;
189
+ font-size: 12px;
190
+ font-weight: 600;
191
+ line-height: 1.2;
192
+ margin-top: 6px;
193
+ }
194
+ .stage-chip-state {
195
+ color: #667085;
196
+ font-size: 11px;
197
+ margin-top: 4px;
198
+ text-transform: capitalize;
199
+ }
200
+ .stage-nav-row button {
201
+ min-height: 42px;
202
+ white-space: normal !important;
203
+ }
204
+ .screen-card {
205
+ border: 1px solid #d9dee7;
206
+ border-radius: 8px;
207
+ background: #ffffff;
208
+ padding: 14px;
209
+ }
210
+ .screen-card h3 {
211
+ color: #111827;
212
+ font-size: 18px;
213
+ margin: 0 0 4px;
214
+ }
215
+ .screen-card p {
216
+ color: #4b5563;
217
+ margin: 0 0 10px;
218
+ }
219
+ .action-strip button {
220
+ min-height: 42px;
221
+ }
222
+ @media (max-width: 900px) {
223
+ .workflow-title-row,
224
+ .workflow-metrics {
225
+ display: block;
226
+ }
227
+ .workflow-progress-card {
228
+ margin-top: 12px;
229
+ text-align: left;
230
+ }
231
+ }
232
  """
233
 
234
  START_PATH_SUMMARIES = {
 
246
  ),
247
  }
248
 
249
+ PASSING_STAGE_SCORE = 80
250
+ RESOLVED_ISSUE_STATUSES = {IssueStatus.RESOLVED, IssueStatus.WAIVED, IssueStatus.WONT_FIX}
251
+ COMPLETED_ARTIFACT_STATUSES = {ArtifactStatus.APPROVED, ArtifactStatus.EXPORTED}
252
+ REVIEW_STAGE_DEFAULTS = {
253
+ "technical_review": ("technical_review", "sme"),
254
+ "pedagogical_review": ("pedagogical_review", "instructional_designer"),
255
+ "aesthetic_review": ("aesthetic_review", "visual_designer"),
256
+ }
257
+ ARTIFACT_WORKSPACE_STAGES = {
258
+ "source_extraction_objective_mapping",
259
+ "slide_outline_order",
260
+ "title_generation",
261
+ "text_generation",
262
+ "image_visual_asset_generation",
263
+ "aesthetic_ordering_visual_composition",
264
+ }
265
+ REVIEW_WORKSPACE_STAGES = {"technical_review", "pedagogical_review", "aesthetic_review"}
266
+ FINAL_WORKSPACE_STAGES = {"final_render_export"}
267
+ AUDIT_WORKSPACE_STAGES = {"audit_log_version_history"}
268
+ HISTORY_WORKSPACE_STAGES = ARTIFACT_WORKSPACE_STAGES | AUDIT_WORKSPACE_STAGES
269
+
270
+ STAGE_UI_CONFIG = {
271
+ "setup_inputs": {
272
+ "short": "Setup",
273
+ "focus": "Collect source material, choose the start path, and lock the deck operating settings.",
274
+ "primary": "Save the working setup, then run preflight before drafting.",
275
+ },
276
+ "source_extraction_objective_mapping": {
277
+ "short": "Sources",
278
+ "focus": "Extract usable source context and connect it to measurable learning objectives.",
279
+ "primary": "Generate mapping",
280
+ },
281
+ "slide_outline_order": {
282
+ "short": "Outline",
283
+ "focus": "Shape the instructional sequence, slide roles, and objective coverage.",
284
+ "primary": "Build outline",
285
+ },
286
+ "title_generation": {
287
+ "short": "Titles",
288
+ "focus": "Draft clear slide titles that make the course arc scannable.",
289
+ "primary": "Generate titles",
290
+ },
291
+ "text_generation": {
292
+ "short": "Text",
293
+ "focus": "Draft visible text and speaker notes with density and grounding checks.",
294
+ "primary": "Generate slide text",
295
+ },
296
+ "image_visual_asset_generation": {
297
+ "short": "Visuals",
298
+ "focus": "Create visual briefs, assets, alt text, and licensing metadata.",
299
+ "primary": "Generate visuals",
300
+ },
301
+ "aesthetic_ordering_visual_composition": {
302
+ "short": "Layout",
303
+ "focus": "Assign layouts, check slot compliance, and refine visual hierarchy.",
304
+ "primary": "Compose layout",
305
+ },
306
+ "technical_review": {
307
+ "short": "Technical",
308
+ "focus": "Review technical accuracy, terminology, and unsupported claims.",
309
+ "primary": "Refresh technical queue",
310
+ },
311
+ "pedagogical_review": {
312
+ "short": "Pedagogy",
313
+ "focus": "Review objective coverage, learning progression, examples, and teachability.",
314
+ "primary": "Refresh pedagogy queue",
315
+ },
316
+ "aesthetic_review": {
317
+ "short": "Aesthetic",
318
+ "focus": "Review readability, accessibility, visual consistency, and template fit.",
319
+ "primary": "Refresh aesthetic queue",
320
+ },
321
+ "final_render_export": {
322
+ "short": "Export",
323
+ "focus": "Run export preflight, resolve blockers, and produce the final deck artifact.",
324
+ "primary": "Final render",
325
+ },
326
+ "audit_log_version_history": {
327
+ "short": "Audit",
328
+ "focus": "Inspect audit events, prompt runs, semantic diffs, and candidate versions.",
329
+ "primary": "Inspect history",
330
+ },
331
+ }
332
+
333
+
334
+ def _safe_stage_id(stage_id: str | None) -> str:
335
+ return stage_id if stage_id in STAGE_IDS else STAGE_IDS[0]
336
+
337
+
338
+ def _short_stage_label(stage_id: str) -> str:
339
+ return STAGE_UI_CONFIG[stage_id]["short"]
340
+
341
+
342
+ def _escape(value: object) -> str:
343
+ return html.escape("" if value is None else str(value), quote=True)
344
+
345
+
346
+ def _is_unresolved_issue(issue) -> bool:
347
+ return issue.status not in RESOLVED_ISSUE_STATUSES and not issue.resolved
348
+
349
+
350
+ def _unresolved_blocker_count(state, stage_id: str | None = None) -> int:
351
+ return sum(
352
+ 1
353
+ for issue in state.issues.values()
354
+ if issue.severity == IssueSeverity.BLOCKER
355
+ and _is_unresolved_issue(issue)
356
+ and (stage_id is None or issue.stage_id == stage_id)
357
+ )
358
+
359
+
360
+ def _stage_status_key(stage_id: str, state) -> str:
361
+ stage = state.stages[stage_id]
362
+ artifact = get_current_stage_artifact(stage_id, state)
363
+ if artifact and artifact.status in COMPLETED_ARTIFACT_STATUSES:
364
+ return "complete"
365
+ if stage.is_stale:
366
+ return "stale"
367
+ if _unresolved_blocker_count(state, stage_id) > 0:
368
+ return "blocked"
369
+ if artifact is None:
370
+ return "pending"
371
+ if stage.score is None or stage.score < PASSING_STAGE_SCORE:
372
+ return "review"
373
+ if can_unlock_next_stage(stage_id, state):
374
+ return "ready"
375
+ if get_stage_lock_reasons(stage_id, state):
376
+ return "review"
377
+ return "ready"
378
+
379
+
380
+ def workflow_progress_summary(state) -> dict[str, Any]:
381
+ total = len(STAGE_IDS)
382
+ complete = 0
383
+ scored_values: list[int] = []
384
+ stale = 0
385
+ for stage_id in STAGE_IDS:
386
+ stage = state.stages[stage_id]
387
+ artifact = get_current_stage_artifact(stage_id, state)
388
+ if artifact and artifact.status in COMPLETED_ARTIFACT_STATUSES:
389
+ complete += 1
390
+ if stage.score is not None:
391
+ scored_values.append(stage.score)
392
+ if stage.is_stale:
393
+ stale += 1
394
+ percent = round((complete / total) * 100) if total else 0
395
+ return {
396
+ "complete": complete,
397
+ "total": total,
398
+ "percent": percent,
399
+ "stale": stale,
400
+ "unresolved_blockers": _unresolved_blocker_count(state),
401
+ "average_stage_score": round(sum(scored_values) / len(scored_values)) if scored_values else None,
402
+ }
403
+
404
+
405
+ def stage_score_rows(state, active_stage_id: str) -> list[dict[str, Any]]:
406
+ active_stage_id = _safe_stage_id(active_stage_id)
407
+ rows = []
408
+ for index, stage_id in enumerate(STAGE_IDS, start=1):
409
+ stage = state.stages[stage_id]
410
+ rows.append(
411
+ {
412
+ "index": index,
413
+ "stage_id": stage_id,
414
+ "label": STAGE_LABELS[stage_id],
415
+ "short_label": _short_stage_label(stage_id),
416
+ "score": stage.score,
417
+ "status": _stage_status_key(stage_id, state),
418
+ "active": stage_id == active_stage_id,
419
+ }
420
+ )
421
+ return rows
422
+
423
+
424
+ def render_workflow_header(state, active_stage_id: str) -> str:
425
+ active_stage_id = _safe_stage_id(active_stage_id)
426
+ summary = workflow_progress_summary(state)
427
+ config = STAGE_UI_CONFIG[active_stage_id]
428
+ average_score = summary["average_stage_score"]
429
+ score_label = "No scores yet" if average_score is None else f"{average_score}/100"
430
+ percent = summary["percent"]
431
+ return f"""
432
+ <div class="workflow-shell">
433
+ <div class="workflow-title-row">
434
+ <div>
435
+ <div class="workflow-kicker">Course Slide Factory</div>
436
+ <h2>{_escape(STAGE_LABELS[active_stage_id])}</h2>
437
+ <p>{_escape(config["focus"])}</p>
438
+ </div>
439
+ <div class="workflow-progress-card">
440
+ <strong>{summary["percent"]}%</strong>
441
+ <span>{summary["complete"]}/{summary["total"]} stages complete</span>
442
+ </div>
443
+ </div>
444
+ <div class="workflow-progress-track">
445
+ <div class="workflow-progress-fill" style="width: {percent}%"></div>
446
+ </div>
447
+ <div class="workflow-metrics">
448
+ <div class="workflow-metric"><span>Average stage score</span><strong>{_escape(score_label)}</strong></div>
449
+ <div class="workflow-metric"><span>Unresolved blockers</span><strong>{summary["unresolved_blockers"]}</strong></div>
450
+ <div class="workflow-metric"><span>Stale stages</span><strong>{summary["stale"]}</strong></div>
451
+ <div class="workflow-metric"><span>Current action</span><strong>{_escape(config["primary"])}</strong></div>
452
+ </div>
453
+ </div>
454
+ """
455
+
456
+
457
+ def render_stage_score_strip(state, active_stage_id: str) -> str:
458
+ chips = []
459
+ for row in stage_score_rows(state, active_stage_id):
460
+ score = "No score" if row["score"] is None else f"{row['score']}/100"
461
+ classes = ["stage-score-chip", row["status"]]
462
+ if row["active"]:
463
+ classes.append("active")
464
+ chips.append(
465
+ f"""
466
+ <div class="{_escape(" ".join(classes))}">
467
+ <div class="stage-chip-top">
468
+ <span class="stage-chip-number">{row["index"]:02d}</span>
469
+ <span class="stage-chip-score">{_escape(score)}</span>
470
+ </div>
471
+ <div class="stage-chip-label">{_escape(row["short_label"])}</div>
472
+ <div class="stage-chip-state">{_escape(row["status"].replace("_", " "))}</div>
473
+ </div>
474
+ """
475
+ )
476
+ return f'<div class="stage-score-grid">{"".join(chips)}</div>'
477
+
478
+
479
+ def stage_visibility_updates(active_stage_id: str):
480
+ active_stage_id = _safe_stage_id(active_stage_id)
481
+ return tuple(gr.update(visible=stage_id == active_stage_id) for stage_id in STAGE_IDS)
482
+
483
+
484
+ def workspace_visibility_updates(active_stage_id: str):
485
+ active_stage_id = _safe_stage_id(active_stage_id)
486
+ return (
487
+ gr.update(visible=active_stage_id in ARTIFACT_WORKSPACE_STAGES),
488
+ gr.update(visible=active_stage_id in REVIEW_WORKSPACE_STAGES),
489
+ gr.update(visible=active_stage_id in FINAL_WORKSPACE_STAGES),
490
+ gr.update(visible=active_stage_id in AUDIT_WORKSPACE_STAGES),
491
+ gr.update(visible=active_stage_id in HISTORY_WORKSPACE_STAGES),
492
+ )
493
+
494
+
495
+ def stage_step_button_updates(active_stage_id: str):
496
+ active_stage_id = _safe_stage_id(active_stage_id)
497
+ return tuple(
498
+ gr.update(
499
+ value=f"{index + 1}. {_short_stage_label(stage_id)}",
500
+ variant="primary" if stage_id == active_stage_id else "secondary",
501
+ )
502
+ for index, stage_id in enumerate(STAGE_IDS)
503
+ )
504
+
505
+
506
+ def _render_chrome_outputs(state, active_stage_id: str):
507
+ active_stage_id = _safe_stage_id(active_stage_id)
508
+ return (
509
+ render_workflow_header(state, active_stage_id),
510
+ render_stage_score_strip(state, active_stage_id),
511
+ *stage_visibility_updates(active_stage_id),
512
+ *workspace_visibility_updates(active_stage_id),
513
+ *stage_step_button_updates(active_stage_id),
514
+ )
515
+
516
+
517
+ def _stage_nav_defaults(stage_id: str) -> tuple[str, str]:
518
+ return REVIEW_STAGE_DEFAULTS.get(stage_id, ("all", "general_reviewer"))
519
+
520
 
521
  def _has_file(file_value) -> bool:
522
  if not file_value:
 
621
  change_set_id: str = "",
622
  prompt_run_id: str = "",
623
  ):
624
+ stage_id = _safe_stage_id(stage_id)
625
  lock_reasons = "\n".join(get_stage_lock_reasons(stage_id, state)) or "Unlocked"
626
  role_summary = review_mode_summary_text(state, review_role)
627
  review_queue = review_queue_table(
 
666
  prompt_runs,
667
  prompt_detail,
668
  metrics,
669
+ *_render_chrome_outputs(state, stage_id),
670
  )
671
 
672
 
 
800
  return _render_outputs(state, stage_id, message)
801
 
802
 
803
+ def handle_stage_nav(state_data, target_stage_id):
804
+ target_stage_id = _safe_stage_id(target_stage_id)
805
  state = state_from_dict(state_data)
806
+ queue_stage, review_role = _stage_nav_defaults(target_stage_id)
807
+ return (
808
+ target_stage_id,
809
+ queue_stage,
810
+ review_role,
811
+ *_render_outputs(
812
+ state,
813
+ target_stage_id,
814
+ f"Selected {STAGE_LABELS[target_stage_id]}.",
815
+ review_role=review_role,
816
+ queue_stage=queue_stage,
817
+ ),
818
+ )
819
 
820
 
821
  def _constraints_from_inputs(
 
1102
  ("Suggest layout switch", "suggest_layout_switch"),
1103
  ("Add alt text", "add_alt_text"),
1104
  ]
1105
+ initial_state = build_empty_state(deck_title="Course Slide Deck", source_url="", template_url="mock://template/course")
1106
+
1107
  with gr.Blocks(title="Course Slide Factory") as demo:
1108
  state_store = gr.State()
1109
+ stage_id = gr.Textbox(value=STAGE_IDS[0], visible=False)
1110
+
1111
+ with gr.Group(elem_classes=["workflow-topbar"]):
1112
+ workflow_header = gr.Markdown(render_workflow_header(initial_state, STAGE_IDS[0]))
1113
+ with gr.Row(elem_classes=["stage-nav-row"]):
1114
+ stage_nav_buttons = [
1115
+ gr.Button(
1116
+ f"{index + 1}. {_short_stage_label(nav_stage_id)}",
1117
+ variant="primary" if nav_stage_id == STAGE_IDS[0] else "secondary",
1118
+ size="sm",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  )
1120
+ for index, nav_stage_id in enumerate(STAGE_IDS)
1121
+ ]
1122
+ stage_score_strip = gr.Markdown(render_stage_score_strip(initial_state, STAGE_IDS[0]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1123
 
1124
+ with gr.Row():
1125
  with gr.Column(scale=2):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  status = gr.Markdown()
1127
+ with gr.Column(scale=1, min_width=240):
1128
+ reviewer_name = gr.Textbox(label="Reviewer", value="human_reviewer")
1129
+
1130
+ stage_screens: list[Any] = []
1131
+
1132
+ with gr.Group(visible=True, elem_classes=["screen-card"]) as setup_screen:
1133
+ gr.Markdown(
1134
+ "### Setup & Inputs\n"
1135
+ "Choose how the deck starts, collect the source material, and save the operating settings."
1136
+ )
1137
+ with gr.Row():
1138
+ with gr.Column(scale=1, min_width=320):
1139
+ deck_title = gr.Textbox(label="Deck title", value="Course Slide Deck")
1140
+ gr.Markdown("#### Start path")
1141
+ with gr.Row():
1142
+ instructions_path = gr.Button("Instructions file")
1143
+ outline_path = gr.Button("Input outline")
1144
+ draft_path = gr.Button("Draft deck")
1145
+ start_mode = gr.Textbox(value="instructions_file", visible=False)
1146
+ selected_start_path = gr.Markdown(START_PATH_SUMMARIES["instructions_file"])
1147
+ with gr.Group(visible=True) as instructions_panel:
1148
+ material_files = gr.File(
1149
+ label="Instructions file",
1150
+ file_count="multiple",
1151
+ file_types=[".txt", ".md", ".csv", ".json", ".pdf", ".docx", ".pptx"],
1152
+ type="filepath",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1153
  )
1154
+ source_url = gr.Textbox(label="Material URL", value="")
1155
+ with gr.Group(visible=False) as outline_panel:
1156
+ outline_text = gr.Textbox(
1157
+ label="Outline",
1158
+ lines=8,
1159
+ placeholder="Opening hook\nCore concept\nWorked example\nPractice prompt\nWrap-up",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1160
  )
1161
+ source_text = gr.Textbox(
1162
+ label="Input information",
1163
+ lines=6,
1164
+ placeholder="Audience, prerequisites, source notes, constraints, examples, or must-cover points.",
 
 
1165
  )
1166
+ with gr.Group(visible=False) as draft_panel:
1167
+ draft_file = gr.File(
1168
+ label="Draft input required (.pptx, .pdf, or image)",
1169
+ file_count="single",
1170
+ file_types=[".pptx", ".pdf", ".png", ".jpg", ".jpeg", ".webp"],
1171
+ type="filepath",
 
 
 
 
 
 
 
1172
  )
1173
+ with gr.Column(scale=1, min_width=320):
1174
+ template_url = gr.Textbox(label="Template URL", value="mock://template/course")
1175
+ output_folder_id = gr.Textbox(label="Output folder ID", value="")
1176
+ mutation_target_url = gr.Textbox(label="Mutation target URL", value="")
1177
+ with gr.Row():
1178
+ dry_run = gr.Checkbox(label="Dry run", value=True)
1179
+ production_export_requested = gr.Checkbox(label="Production export", value=False)
1180
+ objectives_text = gr.Textbox(
1181
+ label="Learning objectives",
1182
+ lines=8,
1183
+ placeholder="obj_1: Explain the core course concept.\nobj_2: Apply the concept to a worked example.",
1184
+ value="",
1185
+ )
1186
+ with gr.Row(elem_classes=["action-strip"]):
1187
+ update_setup = gr.Button("Save Setup", variant="primary")
1188
+ setup_preflight = gr.Button("Run Preflight")
1189
+ setup_generate = gr.Button("Create Setup Candidate")
1190
+ stage_screens.append(setup_screen)
1191
+
1192
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as source_screen:
1193
+ gr.Markdown("### Source Extraction / Objective Mapping\nExtract source chunks and map them to learning objectives.")
1194
+ with gr.Row(elem_classes=["action-strip"]):
1195
+ source_generate = gr.Button("Generate Mapping", variant="primary")
1196
+ source_grade = gr.Button("Grade Mapping")
1197
+ source_approve = gr.Button("Approve Mapping", variant="primary")
1198
+ objective_matrix = gr.Dataframe(
1199
+ headers=[
1200
+ "Objective ID",
1201
+ "Objective",
1202
+ "Mapped Slides",
1203
+ "Coverage Score",
1204
+ "Coverage Status",
1205
+ "Evidence Count",
1206
+ "Issues",
1207
+ ],
1208
+ interactive=False,
1209
+ elem_classes=["compact-panel"],
1210
+ )
1211
+ stage_screens.append(source_screen)
1212
+
1213
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as outline_screen:
1214
+ gr.Markdown("### Slide Outline & Order\nCreate the slide sequence, inspect slide roles, and check coverage.")
1215
+ with gr.Row(elem_classes=["action-strip"]):
1216
+ outline_generate = gr.Button("Build Outline", variant="primary")
1217
+ outline_grade = gr.Button("Grade Outline")
1218
+ outline_approve = gr.Button("Approve Outline", variant="primary")
1219
+ slide_inventory = gr.Dataframe(
1220
+ headers=[
1221
+ "Slide #",
1222
+ "Title",
1223
+ "Role",
1224
+ "Objectives",
1225
+ "Claims",
1226
+ "Visuals",
1227
+ "Aggregate",
1228
+ "Technical",
1229
+ "Pedagogical",
1230
+ "Aesthetic",
1231
+ "Status",
1232
+ "Issues",
1233
+ "Stale?",
1234
+ ],
1235
+ interactive=False,
1236
+ elem_classes=["compact-panel"],
1237
+ )
1238
+ stage_screens.append(outline_screen)
1239
+
1240
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as title_screen:
1241
+ gr.Markdown("### Title Generation\nDraft, review, edit, and approve slide titles.")
1242
+ with gr.Row(elem_classes=["action-strip"]):
1243
+ title_generate = gr.Button("Generate Titles", variant="primary")
1244
+ title_grade = gr.Button("Grade Titles")
1245
+ title_save_edits = gr.Button("Save Title Edits")
1246
+ title_approve = gr.Button("Approve Titles", variant="primary")
1247
+ stage_screens.append(title_screen)
1248
+
1249
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as text_screen:
1250
+ gr.Markdown("### Text Generation\nDraft visible slide text and speaker notes, then improve or approve the result.")
1251
+ with gr.Row(elem_classes=["action-strip"]):
1252
+ text_generate = gr.Button("Generate Slide Text", variant="primary")
1253
+ text_improve = gr.Button("Improve Text with AI")
1254
+ text_save_edits = gr.Button("Save Text Edits")
1255
+ text_grade = gr.Button("Grade Text")
1256
+ text_approve = gr.Button("Approve Text", variant="primary")
1257
+ stage_screens.append(text_screen)
1258
+
1259
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as image_screen:
1260
+ gr.Markdown("### Image / Visual Asset Generation\nGenerate visual asset plans, alt text, and asset readiness metadata.")
1261
+ with gr.Row(elem_classes=["action-strip"]):
1262
+ image_generate = gr.Button("Generate Visuals", variant="primary")
1263
+ image_improve = gr.Button("Improve Visual Plan")
1264
+ image_grade = gr.Button("Grade Visuals")
1265
+ image_approve = gr.Button("Approve Visuals", variant="primary")
1266
+ stage_screens.append(image_screen)
1267
+
1268
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as layout_screen:
1269
+ gr.Markdown(
1270
+ "### Aesthetic Ordering / Visual Composition\n"
1271
+ "Assign layouts, inspect composition, and compare the latest candidate versions."
1272
+ )
1273
+ with gr.Row(elem_classes=["action-strip"]):
1274
+ layout_generate = gr.Button("Compose Layout", variant="primary")
1275
+ layout_improve = gr.Button("Improve Composition")
1276
+ layout_compare = gr.Button("Compare Layout Versions")
1277
+ layout_grade = gr.Button("Grade Layout")
1278
+ layout_approve = gr.Button("Approve Layout", variant="primary")
1279
+ stage_screens.append(layout_screen)
1280
+
1281
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as technical_screen:
1282
+ gr.Markdown("### Technical Review\nFocus on technical accuracy, terminology, unsupported claims, and blocking issues.")
1283
+ with gr.Row(elem_classes=["action-strip"]):
1284
+ technical_refresh = gr.Button("Refresh Technical Queue", variant="primary")
1285
+ technical_fix = gr.Button("Generate Technical Fix")
1286
+ technical_approve = gr.Button("Approve Technical Review", variant="primary")
1287
+ stage_screens.append(technical_screen)
1288
+
1289
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as pedagogical_screen:
1290
+ gr.Markdown("### Pedagogical Review\nFocus on objective coverage, teaching flow, examples, and speaker notes.")
1291
+ with gr.Row(elem_classes=["action-strip"]):
1292
+ pedagogical_refresh = gr.Button("Refresh Pedagogy Queue", variant="primary")
1293
+ pedagogical_fix = gr.Button("Generate Pedagogy Fix")
1294
+ pedagogical_approve = gr.Button("Approve Pedagogical Review", variant="primary")
1295
+ stage_screens.append(pedagogical_screen)
1296
+
1297
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as aesthetic_screen:
1298
+ gr.Markdown("### Aesthetic Review\nFocus on readability, accessibility, brand/template compliance, and layout consistency.")
1299
+ with gr.Row(elem_classes=["action-strip"]):
1300
+ aesthetic_refresh = gr.Button("Refresh Aesthetic Queue", variant="primary")
1301
+ aesthetic_fix = gr.Button("Generate Aesthetic Fix")
1302
+ aesthetic_approve = gr.Button("Approve Aesthetic Review", variant="primary")
1303
+ stage_screens.append(aesthetic_screen)
1304
+
1305
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as final_screen:
1306
+ gr.Markdown("### Final Render & Export\nRun the final readiness checks and export the completed deck.")
1307
+ with gr.Row(elem_classes=["action-strip"]):
1308
+ final_preflight = gr.Button("Run Export Preflight", variant="primary")
1309
+ export = gr.Button("Final Render & Export", variant="stop")
1310
+ stage_screens.append(final_screen)
1311
+
1312
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as audit_screen:
1313
+ gr.Markdown("### Audit Log / Version History\nInspect prompt runs, state, semantic diffs, and candidate versions.")
1314
+ with gr.Row(elem_classes=["action-strip"]):
1315
+ audit_compare = gr.Button("Compare Latest Versions", variant="primary")
1316
+ restore_candidate = gr.Button("Restore Candidate")
1317
+ stage_screens.append(audit_screen)
1318
+
1319
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as artifact_workspace:
1320
+ gr.Markdown("### Current Stage Artifact")
1321
+ with gr.Row():
1322
+ with gr.Column(scale=2):
1323
+ artifact_editor = gr.Textbox(label="Current artifact / human edit", lines=16, interactive=True)
1324
+ artifact_diff = gr.Textbox(label="Artifact diff", lines=10, interactive=False)
1325
+ with gr.Column(scale=1, min_width=320):
1326
+ lock_reasons = gr.Textbox(label="Lock reasons", lines=5, interactive=False)
1327
+ reviewer_summary = gr.Textbox(label="Reviewer summary")
1328
+ requested_changes_json = gr.Textbox(label="Requested changes JSON", lines=6, value="[]")
1329
+ with gr.Accordion("AI revision controls", open=False):
1330
+ with gr.Row():
1331
+ preserve_slide_order = gr.Checkbox(label="Preserve slide order", value=True)
1332
+ preserve_slide_titles = gr.Checkbox(label="Preserve slide titles", value=False)
1333
+ preserve_technical_claims = gr.Checkbox(label="Preserve technical claims", value=True)
1334
+ with gr.Row():
1335
+ preserve_objective_mappings = gr.Checkbox(label="Preserve objective mappings", value=True)
1336
+ preserve_visual_assets = gr.Checkbox(label="Preserve visual assets", value=False)
1337
+ preserve_layout_ids = gr.Checkbox(label="Preserve layout IDs", value=False)
1338
+ with gr.Row():
1339
+ constraint_slide_ids = gr.Textbox(label="Selected slide IDs")
1340
+ allowed_fields = gr.Textbox(label="Allowed fields")
1341
+ blocked_fields = gr.Textbox(label="Blocked fields")
1342
+ reviewer_instruction = gr.Textbox(label="Reviewer instruction", lines=2)
1343
+ with gr.Row():
1344
+ propose_with_constraints = gr.Button("Critique + Propose Changes")
1345
+ targeted_slide_ids = gr.Textbox(label="Target slide IDs")
1346
+ targeted_action = gr.Dropdown(
1347
+ label="Selected slide action",
1348
+ choices=targeted_action_choices,
1349
+ value="rewrite_visible_text",
1350
+ )
1351
+ targeted_improvement = gr.Button("Create Targeted Changes")
1352
+ critique_output = gr.Textbox(label="Latest critique", lines=7, interactive=False)
1353
+ proposed_change_sets = gr.Dataframe(
1354
+ headers=["Change Set ID", "Stage", "Status", "Artifact", "Changes", "Summary", "Created At"],
1355
+ interactive=False,
1356
+ elem_classes=["compact-panel"],
1357
+ )
1358
+ with gr.Row():
1359
+ selected_change_set_id = gr.Textbox(label="Change set ID")
1360
+ selected_change_ids = gr.Textbox(label="Selected change IDs")
1361
+ apply_changes = gr.Button("Apply Selected Proposed Changes", variant="primary")
1362
+ reject_changes = gr.Button("Reject Change Set")
1363
+ proposed_changes = gr.Dataframe(
1364
+ headers=[
1365
+ "Change Set",
1366
+ "Change ID",
1367
+ "Target Type",
1368
+ "Target ID",
1369
+ "Field",
1370
+ "Before",
1371
+ "After",
1372
+ "Rationale",
1373
+ "Issues",
1374
+ "Risk",
1375
+ ],
1376
+ interactive=False,
1377
+ elem_classes=["compact-panel"],
1378
+ )
1379
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as review_workspace:
1380
+ gr.Markdown("### Review Workbench")
1381
+ with gr.Row():
1382
+ review_role = gr.Dropdown(label="Review role", choices=review_role_options, value="general_reviewer", interactive=True)
1383
+ queue_stage = gr.Dropdown(label="Stage filter", choices=queue_stage_choices, value="all", interactive=True)
1384
+ queue_severity = gr.Dropdown(label="Severity filter", choices=severity_choices, value="all", interactive=True)
1385
+ with gr.Row():
1386
+ queue_status = gr.Dropdown(label="Status filter", choices=status_choices, value="all", interactive=True)
1387
+ queue_issue_type = gr.Dropdown(label="Issue type filter", choices=issue_type_choices, value="all", interactive=True)
1388
+ assigned_filter = gr.Textbox(label="Assigned reviewer filter")
1389
+ role_summary = gr.Textbox(label="Review mode", lines=3, interactive=False)
1390
+ refresh_review = gr.Button("Refresh Review Workbench")
1391
+ review_queue = gr.Dataframe(
1392
+ headers=[
1393
+ "Severity",
1394
+ "Priority",
1395
+ "Status",
1396
+ "Stage",
1397
+ "Slide",
1398
+ "Role",
1399
+ "Issue Type",
1400
+ "Message",
1401
+ "Suggested Fix",
1402
+ "Assigned To",
1403
+ "Created At",
1404
+ "Issue ID",
1405
+ ],
1406
+ interactive=False,
1407
+ elem_classes=["compact-panel"],
1408
+ )
1409
+ with gr.Row():
1410
+ selected_issue_id = gr.Textbox(label="Issue ID")
1411
+ issue_action = gr.Dropdown(
1412
+ label="Triage action",
1413
+ choices=[
1414
+ ("Acknowledge", "acknowledge"),
1415
+ ("Mark in progress", "in_progress"),
1416
+ ("Resolve", "resolve"),
1417
+ ("Waive", "waive"),
1418
+ ("Won't fix", "wont_fix"),
1419
+ ("Reopen", "reopen"),
1420
+ ("Assign", "assign"),
1421
+ ],
1422
+ value="acknowledge",
1423
+ )
1424
+ triage_note = gr.Textbox(label="Reason or note")
1425
+ with gr.Row():
1426
+ assigned_role = gr.Dropdown(label="Assign role", choices=[("No role", "all"), *review_role_options], value="all")
1427
+ assigned_to = gr.Textbox(label="Assign reviewer")
1428
+ triage_issue = gr.Button("Apply Triage")
1429
+ generate_issue_fix = gr.Button("Generate Suggested Fix")
1430
+ suggested_fixes = gr.Dataframe(
1431
+ headers=["Fix ID", "Type", "Stage", "Slides", "Issues", "Risk", "Description"],
1432
+ interactive=False,
1433
+ elem_classes=["compact-panel"],
1434
+ )
1435
+ with gr.Row():
1436
+ fast_generate_grade = gr.Button("Generate + Grade Current Stage")
1437
+ fast_draft_ahead = gr.Button("Draft Ahead Until Review Needed")
1438
+ with gr.Row():
1439
+ packet_stage_ids = gr.Textbox(label="Packet stage IDs")
1440
+ packet_slide_ids = gr.Textbox(label="Packet slide IDs")
1441
+ packet_format = gr.Radio(label="Packet format", choices=[("Markdown", "markdown"), ("JSON", "json")], value="markdown")
1442
+ create_packet = gr.Button("Create Review Packet")
1443
+ reviewer_metrics = gr.Dataframe(headers=["Metric", "Value"], interactive=False, elem_classes=["compact-panel"])
1444
+ issues = gr.Dataframe(
1445
+ headers=["Issue ID", "Type", "Severity", "Stage", "Slide", "Message", "Resolved?"],
1446
+ interactive=False,
1447
+ elem_classes=["compact-panel"],
1448
+ )
1449
+
1450
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as final_workspace:
1451
+ gr.Markdown("### Export Readiness")
1452
+ deck_health = gr.Dataframe(headers=["Metric", "Value"], interactive=False, elem_classes=["compact-panel"])
1453
+ preflight_results = gr.Dataframe(headers=["Field", "Value"], interactive=False, elem_classes=["compact-panel"])
1454
+ stage_status = gr.Dataframe(
1455
+ headers=["#", "Stage", "Score", "Artifact", "Artifact Status", "Stale?", "Can Unlock Next?", "Lock Reasons"],
1456
+ interactive=False,
1457
+ elem_classes=["compact-panel"],
1458
+ )
1459
+
1460
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as audit_workspace:
1461
+ gr.Markdown("### Audit & Version Details")
1462
+ with gr.Row():
1463
+ restore_artifact_id = gr.Textbox(label="Artifact version ID")
1464
+ audit = gr.Dataframe(
1465
+ headers=["Event ID", "Type", "Timestamp", "Stage", "Slide", "Issue", "Reason"],
1466
+ interactive=False,
1467
+ elem_classes=["compact-panel"],
1468
+ )
1469
+ state_json = gr.Textbox(label="State", lines=18, interactive=False)
1470
+
1471
+ with gr.Group(visible=False, elem_classes=["screen-card"]) as history_workspace:
1472
+ gr.Markdown("### Version History")
1473
+ semantic_compare = gr.Button("Compare Latest Versions")
1474
+ semantic_diff = gr.Textbox(label="Semantic diff", lines=8, interactive=False)
1475
+ prompt_runs = gr.Dataframe(
1476
+ headers=[
1477
+ "Prompt Run ID",
1478
+ "Created At",
1479
+ "Stage",
1480
+ "Provider",
1481
+ "Model",
1482
+ "Template",
1483
+ "Inputs",
1484
+ "Output",
1485
+ "Settings",
1486
+ ],
1487
+ interactive=False,
1488
+ elem_classes=["compact-panel"],
1489
+ )
1490
+ prompt_run_id = gr.Textbox(label="Prompt run ID")
1491
+ prompt_detail = gr.Textbox(label="Prompt run detail", lines=8, interactive=False)
1492
 
1493
  outputs = [
1494
  state_store,
 
1514
  prompt_runs,
1515
  prompt_detail,
1516
  reviewer_metrics,
1517
+ workflow_header,
1518
+ stage_score_strip,
1519
+ *stage_screens,
1520
+ artifact_workspace,
1521
+ review_workspace,
1522
+ final_workspace,
1523
+ audit_workspace,
1524
+ history_workspace,
1525
+ *stage_nav_buttons,
1526
  ]
1527
  setup_inputs = [
1528
  state_store,
 
1596
  blocked_fields,
1597
  reviewer_instruction,
1598
  ]
1599
+ start_path_outputs = [start_mode, selected_start_path, instructions_panel, outline_panel, draft_panel]
1600
+ nav_outputs = [stage_id, queue_stage, review_role, *outputs]
 
 
 
 
 
1601
 
1602
+ def _nav_handler(target_stage_id):
1603
+ def _handle(state_data):
1604
+ return handle_stage_nav(state_data, target_stage_id)
1605
+
1606
+ return _handle
1607
+
1608
+ demo.load(handle_update_setup, inputs=setup_inputs, outputs=outputs, show_progress="hidden")
1609
  instructions_path.click(select_instructions_path, inputs=[], outputs=start_path_outputs)
1610
  outline_path.click(select_outline_path, inputs=[], outputs=start_path_outputs)
1611
  draft_path.click(select_draft_path, inputs=[], outputs=start_path_outputs)
1612
+
1613
+ for button, target_stage_id in zip(stage_nav_buttons, STAGE_IDS, strict=True):
1614
+ button.click(_nav_handler(target_stage_id), inputs=[state_store], outputs=nav_outputs, show_progress="hidden")
1615
+
1616
  update_setup.click(handle_update_setup, inputs=setup_inputs, outputs=outputs)
1617
+ for button in [
1618
+ setup_generate,
1619
+ source_generate,
1620
+ outline_generate,
1621
+ title_generate,
1622
+ text_generate,
1623
+ image_generate,
1624
+ layout_generate,
1625
+ ]:
1626
+ button.click(handle_generate, inputs=setup_inputs, outputs=outputs)
1627
+ for button in [source_grade, outline_grade, title_grade, text_grade, image_grade, layout_grade]:
1628
+ button.click(handle_grade, inputs=[state_store, stage_id], outputs=outputs)
1629
+ for button in [text_improve, image_improve, layout_improve]:
1630
+ button.click(handle_improve, inputs=[state_store, stage_id], outputs=outputs)
1631
+ for button in [
1632
+ source_approve,
1633
+ outline_approve,
1634
+ title_approve,
1635
+ text_approve,
1636
+ image_approve,
1637
+ layout_approve,
1638
+ technical_approve,
1639
+ pedagogical_approve,
1640
+ aesthetic_approve,
1641
+ ]:
1642
+ button.click(handle_approve, inputs=[state_store, stage_id, reviewer_name, review_role], outputs=outputs)
1643
+ for button in [title_save_edits, text_save_edits]:
1644
+ button.click(
1645
+ handle_save_edits,
1646
+ inputs=[state_store, stage_id, artifact_editor, reviewer_name, reviewer_summary, requested_changes_json],
1647
+ outputs=outputs,
1648
+ )
1649
+ for button in [setup_preflight, final_preflight]:
1650
+ button.click(handle_preflight, inputs=[state_store, stage_id], outputs=outputs)
1651
  export.click(handle_export, inputs=[state_store, stage_id], outputs=outputs)
1652
+
1653
+ for button in [refresh_review, technical_refresh, pedagogical_refresh, aesthetic_refresh]:
1654
+ button.click(handle_review_refresh, inputs=review_filter_inputs, outputs=outputs)
1655
  for filter_component in [
1656
  review_role,
1657
  queue_stage,
 
1662
  selected_change_set_id,
1663
  prompt_run_id,
1664
  ]:
1665
+ filter_component.change(handle_review_refresh, inputs=review_filter_inputs, outputs=outputs, show_progress="hidden")
 
 
 
 
 
1666
  triage_issue.click(handle_triage_issue, inputs=triage_inputs, outputs=outputs)
1667
+ for button in [generate_issue_fix, technical_fix, pedagogical_fix, aesthetic_fix]:
1668
+ button.click(handle_suggested_fix, inputs=suggested_fix_inputs, outputs=outputs)
1669
+
1670
+ propose_with_constraints.click(handle_propose_with_constraints, inputs=[state_store, stage_id, *constraint_inputs], outputs=outputs)
 
 
1671
  targeted_improvement.click(
1672
  handle_targeted_improvement,
1673
+ inputs=[state_store, stage_id, targeted_slide_ids, targeted_action, *constraint_inputs],
 
 
 
 
 
 
1674
  outputs=outputs,
1675
  )
1676
  apply_changes.click(
 
1683
  inputs=[state_store, stage_id, selected_change_set_id, reviewer_name, triage_note],
1684
  outputs=outputs,
1685
  )
1686
+ for button in [semantic_compare, layout_compare, audit_compare]:
1687
+ button.click(handle_semantic_compare, inputs=[state_store, stage_id], outputs=outputs)
1688
+ fast_generate_grade.click(handle_fast_generate_grade, inputs=[state_store, stage_id], outputs=outputs)
1689
+ fast_draft_ahead.click(handle_fast_draft_ahead, inputs=[state_store, stage_id], outputs=outputs)
 
 
 
 
 
 
 
1690
  create_packet.click(
1691
  handle_review_packet,
1692
+ inputs=[state_store, stage_id, review_role, packet_stage_ids, packet_slide_ids, packet_format],
 
 
 
 
 
 
 
1693
  outputs=outputs,
1694
  )
1695
  restore_candidate.click(
tests/test_app_ui.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app import render_stage_score_strip, stage_score_rows, workflow_progress_summary
4
+ from course_slide_factory.constants import STAGE_IDS
5
+ from course_slide_factory.models import IssueSeverity, IssueType
6
+ from course_slide_factory.quality import approve_current_artifact, upsert_issue
7
+ from course_slide_factory.workflow import build_empty_state, generate_stage
8
+
9
+
10
+ def test_workflow_progress_summary_starts_empty():
11
+ state = build_empty_state(deck_title="UI Test", source_url="mock://source")
12
+
13
+ summary = workflow_progress_summary(state)
14
+ rows = stage_score_rows(state, "setup_inputs")
15
+
16
+ assert summary["complete"] == 0
17
+ assert summary["total"] == len(STAGE_IDS)
18
+ assert summary["percent"] == 0
19
+ assert rows[0]["stage_id"] == "setup_inputs"
20
+ assert rows[0]["active"]
21
+ assert rows[0]["score"] is None
22
+ assert rows[0]["status"] == "pending"
23
+
24
+
25
+ def test_stage_score_rows_track_complete_review_stale_and_blockers():
26
+ state = build_empty_state(deck_title="UI Test", source_url="mock://source")
27
+ state, _message = generate_stage(state, "setup_inputs")
28
+ state.stages["setup_inputs"].score = 92
29
+ approve_current_artifact(state, "setup_inputs")
30
+
31
+ state, _message = generate_stage(state, "text_generation")
32
+ state.stages["text_generation"].score = 72
33
+ state.stages["title_generation"].is_stale = True
34
+ upsert_issue(
35
+ state,
36
+ IssueType.TECHNICAL_INACCURACY,
37
+ IssueSeverity.BLOCKER,
38
+ "Blocking issue",
39
+ stage_id="technical_review",
40
+ )
41
+ state.stages["technical_review"].is_stale = False
42
+
43
+ summary = workflow_progress_summary(state)
44
+ rows = {row["stage_id"]: row for row in stage_score_rows(state, "text_generation")}
45
+ score_strip = render_stage_score_strip(state, "text_generation")
46
+
47
+ assert summary["complete"] == 1
48
+ assert summary["percent"] == round(1 / len(STAGE_IDS) * 100)
49
+ assert summary["unresolved_blockers"] >= 1
50
+ assert rows["setup_inputs"]["status"] == "complete"
51
+ assert rows["text_generation"]["status"] == "review"
52
+ assert rows["text_generation"]["active"]
53
+ assert rows["title_generation"]["status"] == "stale"
54
+ assert rows["technical_review"]["status"] == "blocked"
55
+ assert "Text" in score_strip
56
+ assert "72/100" in score_strip