IT-BOS commited on
Commit
f80cf3f
·
verified ·
1 Parent(s): 67d4b81

Upload 8 files

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. app.py +390 -229
  3. modules.py +30 -0
README.md CHANGED
@@ -10,7 +10,7 @@ pinned: false
10
 
11
  # BOS AI Training Platform
12
 
13
- Six module course on using AI at BOS Security. Lessons are in `modules.py`, knowledge checks in `quizzes.py`, the tools by team reference in `teams.py`, the SVG diagrams in `figures.py`, and UI and styling in `app.py`. Upload `logo.png` alongside them.
14
 
15
  To edit a module, change the matching entry in `MODULES` in `modules.py`. To change the help text shown at the bottom of every module, edit `IT_HELP`.
16
 
 
10
 
11
  # BOS AI Training Platform
12
 
13
+ Six module course on using AI at BOS Security. Pages: an Overview with session progress, six modules, and a Tools by team reference. Progress lives in the browser session only and resets on reload. Lessons and key takeaways are in `modules.py`, knowledge checks in `quizzes.py`, the tools by team reference in `teams.py`, the SVG diagrams in `figures.py`, and UI and styling in `app.py`. Upload `logo.png` alongside them.
14
 
15
  To edit a module, change the matching entry in `MODULES` in `modules.py`. To change the help text shown at the bottom of every module, edit `IT_HELP`.
16
 
app.py CHANGED
@@ -3,12 +3,16 @@ BOS AI Training Platform
3
  A guided, module based course on using AI at BOS Security, delivered as a
4
  HuggingFace Gradio Space.
5
 
6
- Content: modules.py (lessons), quizzes.py (knowledge checks), teams.py (tools by team).
7
- Logo: logo.png next to this file.
 
 
 
8
  """
9
 
10
  import base64
11
  import html
 
12
  from pathlib import Path
13
 
14
  import gradio as gr
@@ -25,7 +29,7 @@ TEAL_DEEP = "#0E7F78"
25
  INK = "#1B2430"
26
  MUTED = "#61707E"
27
  PAPER = "#FFFFFF"
28
- WASH = "#F4F7F9"
29
  RULE = "#E1E8ED"
30
  MISS = "#C0563A"
31
 
@@ -35,91 +39,158 @@ if LOGO_PATH.exists():
35
  LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
36
 
37
  N = len(MODULES)
38
- TEAM_PAGE = N # index of the Tools by team page in the rail
39
  MAXQ = max(len(q) for q in QUIZZES)
 
 
 
 
 
 
 
 
 
40
 
41
  CSS = f"""
42
- :root {{ --blue: {BLUE}; --blue-deep: {BLUE_DEEP}; --teal: {TEAL}; --teal-deep: {TEAL_DEEP};
43
- --ink: {INK}; --muted: {MUTED}; --paper: {PAPER}; --wash: {WASH}; --rule: {RULE}; --miss: {MISS}; }}
 
44
  * {{ font-family: Arial, Helvetica, sans-serif !important; }}
45
  body, .gradio-container {{ background: var(--wash) !important; color: var(--ink); }}
46
- .gradio-container {{ max-width: 1280px !important; margin: 0 auto !important; padding: 20px 20px 56px !important; }}
47
  footer {{ display: none !important; }}
48
-
49
- /* Masthead */
50
- .masthead {{ display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap;
51
- background: var(--paper); border: 1px solid var(--rule); border-radius: 14px; padding: 18px 26px; }}
52
- .masthead .brand {{ display: flex; align-items: center; gap: 22px; }}
53
- .masthead img {{ height: 52px; width: auto; display: block; }}
54
- .masthead .divider {{ width: 1px; height: 44px; background: var(--rule); }}
55
- .masthead h1 {{ margin: 0; font-size: 22px; font-weight: 700; color: var(--blue); letter-spacing: -0.01em; }}
56
- .masthead .tag {{ margin: 3px 0 0; font-size: 14px; color: var(--muted); }}
57
- .masthead .tool {{ font-size: 13px; color: var(--muted); padding: 8px 14px; border: 1px solid var(--rule);
58
- border-radius: 999px; background: var(--wash); }}
59
- .masthead .tool b {{ color: var(--blue); }}
60
-
61
- /* Progress bar */
62
- .track {{ height: 6px; background: var(--rule); border-radius: 999px; overflow: hidden; margin: 14px 4px 4px; }}
63
- .track div {{ height: 100%; background: var(--teal); border-radius: 999px; transition: width .35s ease; }}
 
 
64
 
65
  /* Rail */
66
- .rail {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 14px !important;
67
- padding: 14px 10px !important; position: sticky; top: 16px; align-self: flex-start; gap: 4px !important; }}
68
- .rail .rail-head {{ padding: 4px 12px 10px; font-size: 13px; color: var(--muted); }}
69
- .rail .rail-sub {{ padding: 14px 12px 6px; font-size: 13px; color: var(--muted); border-top: 1px solid var(--rule); margin-top: 6px; }}
70
- .rail button {{ width: 100%; justify-content: flex-start !important; text-align: left !important;
71
- padding: 12px 14px !important; margin: 0 !important; border-radius: 10px !important;
72
- font-size: 15px !important; line-height: 1.35 !important; font-weight: 400 !important;
73
- white-space: normal !important; transition: background .15s ease, color .15s ease, transform .1s ease; }}
74
  .rail button.secondary {{ background: transparent !important; border: 1px solid transparent !important; color: var(--ink) !important; box-shadow: none !important; }}
75
  .rail button.secondary:hover {{ background: var(--wash) !important; }}
76
- .rail button.primary {{ background: #E7F6F4 !important; border: 1px solid #C6EBE7 !important; color: var(--blue) !important;
77
- font-weight: 700 !important; box-shadow: none !important; }}
 
78
  .rail button:active {{ transform: scale(.985); }}
79
  .rail button:focus-visible {{ outline: 2px solid var(--teal) !important; outline-offset: 2px; }}
80
 
81
- /* Reading column */
82
- .reader {{ background: var(--paper); border: 1px solid var(--rule); border-radius: 14px; padding: 44px 60px 36px;
83
- animation: rise .28s ease both; }}
84
- @keyframes rise {{ from {{ opacity: 0; transform: translateY(6px); }} to {{ opacity: 1; transform: none; }} }}
85
- @media (prefers-reduced-motion: reduce) {{ .reader, .result {{ animation: none; }} .track div {{ transition: none; }} }}
86
- .reader .meta {{ display: flex; align-items: center; gap: 12px; margin-bottom: 14px; font-size: 13px; color: var(--muted); }}
87
- .reader .pill {{ display: inline-block; padding: 4px 11px; border-radius: 999px; font-weight: 700; font-size: 12px;
88
- background: #E7F6F4; color: var(--teal-deep); }}
89
- .reader .pill.required {{ background: var(--blue); color: #fff; }}
90
- .reader h2 {{ font-size: 34px; line-height: 1.12; color: var(--blue); margin: 0 0 10px; letter-spacing: -0.02em; }}
91
- .reader .summary {{ font-size: 18px; color: var(--muted); margin: 0 0 32px; padding-bottom: 24px;
92
- border-bottom: 1px solid var(--rule); max-width: 64ch; line-height: 1.45; }}
93
- .reader h3 {{ font-size: 20px; color: var(--blue); margin: 36px 0 10px; letter-spacing: -0.01em; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  .reader h3:first-of-type {{ margin-top: 0; }}
95
- .reader p {{ font-size: 16.5px; line-height: 1.65; margin: 0 0 14px; max-width: 70ch; color: var(--ink); }}
96
  .reader p b {{ color: var(--blue); }}
97
- .reader .help {{ margin-top: 44px; padding: 20px 24px; background: var(--wash); border-radius: 12px; border-left: 4px solid var(--teal); }}
98
- .reader .help p {{ margin: 0; max-width: none; font-size: 15.5px; }}
99
- .reader .help p b {{ color: var(--teal-deep); }}
 
 
 
 
 
 
 
 
100
 
101
  /* Quiz */
102
- .quiz {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 14px !important;
103
- padding: 28px 36px 26px !important; margin-top: 16px; gap: 6px !important; }}
104
- .quiz .qhead h3 {{ font-size: 20px; color: var(--blue); margin: 0 0 4px; }}
105
- .quiz .qhead p {{ color: var(--muted); font-size: 15px; margin: 0 0 12px; max-width: 70ch; }}
 
 
 
 
106
  .quiz fieldset {{ border: none !important; padding: 0 !important; margin: 0 !important; }}
107
- .quiz .block {{ border: none !important; background: transparent !important; padding: 6px 0 !important; box-shadow: none !important; }}
108
- .quiz span[data-testid="block-info"] {{ font-size: 16px !important; font-weight: 700 !important; color: var(--ink) !important;
109
- margin-bottom: 10px !important; display: block; line-height: 1.4; }}
110
- .quiz .wrap {{ display: flex; flex-direction: column; gap: 6px !important; }}
111
- .quiz .wrap label {{ display: flex !important; align-items: center; gap: 12px; padding: 11px 14px !important;
112
- border: 1px solid var(--rule) !important; border-radius: 10px !important; background: var(--paper) !important;
113
- cursor: pointer; font-size: 15px; box-shadow: none !important; transition: border-color .12s ease, background .12s ease; }}
114
- .quiz .wrap label:hover {{ border-color: var(--teal) !important; }}
115
- .quiz .wrap label.selected {{ border-color: var(--teal) !important; background: #E7F6F4 !important; color: var(--blue); }}
116
- .quiz .wrap label input {{ accent-color: var(--teal); }}
117
- .quiz .wrap label span {{ white-space: normal !important; }}
118
- .quiz .check {{ background: var(--blue) !important; border: 1px solid var(--blue) !important; color: #fff !important; box-shadow: none !important;
119
- border-radius: 10px !important; font-weight: 700 !important; padding: 12px 22px !important; margin-top: 8px; width: auto; }}
 
120
  .quiz .check:hover {{ background: var(--blue-deep) !important; }}
121
- .result {{ margin-top: 12px; animation: rise .25s ease both; }}
122
- .result .score {{ font-size: 18px; font-weight: 700; color: var(--blue); margin: 0 0 12px; }}
 
 
 
 
123
  .result .item {{ padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 15px; line-height: 1.5; }}
124
  .result .item.ok {{ background: #E7F6F4; border-left: 4px solid var(--teal); }}
125
  .result .item.miss {{ background: #FBEFEA; border-left: 4px solid var(--miss); }}
@@ -129,39 +200,19 @@ footer {{ display: none !important; }}
129
  .result .warn {{ color: var(--miss); font-weight: 700; }}
130
 
131
  /* Tools by team */
132
- .teamwrap .tabs {{ margin-top: 12px; background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 14px !important; padding: 10px 40px 32px !important; }}
133
- .teamwrap .tabs button {{ font-size: 15px !important; font-weight: 700 !important; color: var(--muted) !important; border: none !important;
134
- border-bottom: 3px solid transparent !important; border-radius: 0 !important; padding: 14px 18px !important;
135
- background: transparent !important; box-shadow: none !important; }}
136
  .teamwrap .tabs button.selected {{ color: var(--blue) !important; border-bottom-color: var(--teal) !important; }}
137
- .teamwrap .tab-nav {{ border-bottom: 1px solid var(--rule) !important; gap: 2px; }}
138
- .teamwrap .tab-nav button {{ font-size: 15px !important; font-weight: 700 !important; color: var(--muted) !important; border: none !important;
139
- border-bottom: 3px solid transparent !important; border-radius: 0 !important; padding: 12px 18px !important;
140
- background: transparent !important; box-shadow: none !important; }}
141
- .teamwrap .tab-nav button.selected {{ color: var(--blue) !important; border-bottom-color: var(--teal) !important; }}
142
  .teamwrap .tabitem {{ border: none !important; padding: 4px 0 0 !important; background: transparent !important; }}
143
  .teamwrap .tabitem h3 {{ font-size: 19px; color: var(--blue); margin: 26px 0 8px; }}
144
- .teamwrap .tabitem h3:first-child {{ margin-top: 10px; }}
145
- .teamwrap .tabitem p {{ font-size: 16px; line-height: 1.65; margin: 0 0 12px; max-width: 72ch; color: var(--ink); }}
146
  .teamwrap .tabitem p b {{ color: var(--blue); }}
147
 
148
- /* Figures */
149
- .fig {{ margin: 22px 0 28px; padding: 22px 24px 14px; background: var(--wash); border: 1px solid var(--rule); border-radius: 12px; max-width: 760px; }}
150
- .fig svg {{ width: 100%; height: auto; display: block; }}
151
- .fig figcaption {{ margin-top: 12px; font-size: 13.5px; color: var(--muted); line-height: 1.45; }}
152
-
153
- /* Quiz contrast: override dark theme and block chrome no matter what the host sets */
154
- .quiz, .quiz .block, .quiz .form, .quiz fieldset, .quiz .wrap {{ background: var(--paper) !important; color: var(--ink) !important; }}
155
- .quiz .block, .quiz .form {{ border: none !important; box-shadow: none !important; padding: 10px 0 6px !important; margin: 0 !important; }}
156
- .quiz span[data-testid="block-info"], .quiz .block > label > span, .quiz legend {{ color: var(--ink) !important; background: transparent !important; }}
157
- .quiz .wrap label, .quiz .wrap label span, .quiz .wrap label * {{ color: var(--ink) !important; }}
158
- .quiz .wrap label input[type="radio"] {{ appearance: auto !important; -webkit-appearance: radio !important; width: 18px; height: 18px; margin: 0; accent-color: var(--teal); background: #fff !important; border: 1px solid var(--muted) !important; border-radius: 50% !important; box-shadow: none !important; }}
159
- .quiz .wrap label.selected span {{ color: var(--blue) !important; font-weight: 700; }}
160
-
161
  /* Nav */
162
  .navrow {{ margin-top: 14px; align-items: center; }}
163
- .navrow button {{ border-radius: 10px !important; font-weight: 700 !important; font-size: 14px !important;
164
- padding: 12px 20px !important; transition: background .15s ease, transform .1s ease; }}
165
  .navrow button:active {{ transform: scale(.98); }}
166
  .navrow .primary {{ background: var(--teal) !important; border: 1px solid var(--teal) !important; color: #fff !important; box-shadow: none !important; }}
167
  .navrow .primary:hover {{ background: var(--teal-deep) !important; }}
@@ -169,25 +220,138 @@ footer {{ display: none !important; }}
169
  .navrow .secondary:hover {{ background: var(--wash) !important; }}
170
  .progress {{ text-align: center; color: var(--muted); font-size: 14px; }}
171
 
172
- @media (max-width: 900px) {{
173
- .rail {{ position: static; }}
174
- .reader {{ padding: 28px 22px; }}
175
- .quiz {{ padding: 22px 20px !important; }}
176
- .reader h2 {{ font-size: 27px; }}
177
- .masthead .divider {{ display: none; }}
178
  }}
179
  """
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  # Rendering helpers
183
 
184
- def render_module(idx: int) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  m = MODULES[idx]
186
- pill_cls = "pill required" if m["depth"].lower() == "required" else "pill"
 
 
 
187
  parts = [
188
- '<div class="reader">',
189
- f'<div class="meta"><span class="{pill_cls}">{html.escape(m["depth"])}</span>'
190
- f'<span>Module {idx + 1} of {N}</span></div>',
191
  f'<h2>{html.escape(m["title"])}</h2>',
192
  f'<p class="summary">{html.escape(m["summary"])}</p>',
193
  ]
@@ -197,40 +361,62 @@ def render_module(idx: int) -> str:
197
  fig = FIGURES.get((idx, s["heading"]))
198
  if fig:
199
  parts.append(figure(*fig))
 
200
  parts.append(f'<div class="help"><p><b>Need help?</b> {html.escape(IT_HELP)}</p></div>')
201
  parts.append("</div>")
202
  return "\n".join(parts)
203
 
204
 
205
- def figure(svg_markup: str, caption: str) -> str:
206
- return f'<figure class="fig">{svg_markup}<figcaption>{html.escape(caption)}</figcaption></figure>'
207
-
208
-
209
  def render_team_header() -> str:
210
  return (
211
- '<div class="reader" style="padding-bottom:20px">'
212
  '<div class="meta"><span class="pill">Reference</span><span>Tools by team</span></div>'
213
  "<h2>Where Claude fits next to Zoho, Apollo, and Microsoft 365</h2>"
214
  f"{TEAMS_INTRO}{figure(*TEAM_FIGURE)}</div>"
215
  )
216
 
217
 
218
- def track(idx: int) -> str:
219
- pct = 100 if idx >= N else round((idx + 1) / N * 100)
220
- return f'<div class="track"><div style="width:{pct}%"></div></div>'
 
 
 
 
221
 
 
 
 
 
 
 
222
 
223
- def progress_text(idx: int) -> str:
224
- return '<div class="progress">Reference</div>' if idx >= N else f'<div class="progress">{idx + 1} of {N}</div>'
225
 
 
 
 
 
 
 
 
226
 
227
- def rail_updates(idx: int):
228
- return [gr.update(variant="primary" if i == idx else "secondary") for i in range(N + 1)]
229
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
- def quiz_updates(idx: int):
232
- """Return updates for the MAXQ radio components for module idx."""
233
- qs = QUIZZES[idx] if idx < N else []
234
  out = []
235
  for i in range(MAXQ):
236
  if i < len(qs):
@@ -240,128 +426,101 @@ def quiz_updates(idx: int):
240
  return out
241
 
242
 
243
- def grade(idx: int, *answers) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  qs = QUIZZES[idx]
245
  unanswered = [i + 1 for i, a in enumerate(answers[: len(qs)]) if a is None]
246
  if unanswered:
247
  nums = ", ".join(str(n) for n in unanswered)
248
- return f'<div class="result"><p class="warn">Answer question {nums} before checking.</p></div>'
249
- items = []
250
- correct = 0
 
251
  for i, q in enumerate(qs):
252
- chosen = answers[i]
253
- right = q["options"][q["answer"]]
254
  if chosen == right:
255
  correct += 1
256
- items.append(
257
- f'<div class="item ok"><p><b>Question {i + 1}.</b> Correct.</p>'
258
- f'<p>{html.escape(right)}</p></div>'
259
- )
260
  else:
261
- items.append(
262
- f'<div class="item miss"><p><b>Question {i + 1}.</b> Not quite. You chose: {html.escape(chosen)}</p>'
263
- f'<p><b>Correct answer:</b> {html.escape(right)}</p>'
264
- f'<p>{html.escape(q["why"])}</p></div>'
265
- )
266
- score = f'<p class="score">You got {correct} of {len(qs)} right.</p>'
267
  if correct == len(qs):
268
- score = f'<p class="score">All {len(qs)} correct. Move on to the next module.</p>'
269
- return f'<div class="result">{score}{"".join(items)}</div>'
270
-
 
 
 
 
 
271
 
272
- def show(idx: int):
273
- idx = max(0, min(TEAM_PAGE, idx))
274
- on_module = idx < N
275
- return [
276
- idx,
277
- gr.update(visible=on_module), # module_view
278
- gr.update(visible=not on_module), # team_view
279
- render_module(idx if on_module else 0),
280
- track(idx),
281
- progress_text(idx),
282
- gr.update(interactive=idx > 0),
283
- gr.update(interactive=idx < TEAM_PAGE, value="Next module" if idx < N - 1 else "Tools by team"),
284
- "", # clear quiz result
285
- *rail_updates(idx),
286
- *quiz_updates(idx),
287
- ]
288
-
289
-
290
- FORCE_LIGHT = """
291
- () => {
292
- const strip = () => {
293
- document.body.classList.remove('dark');
294
- document.documentElement.classList.remove('dark');
295
- document.querySelectorAll('.dark').forEach(el => el.classList.remove('dark'));
296
- };
297
- strip();
298
- new MutationObserver(strip).observe(document.body, { attributes: true, attributeFilter: ['class'] });
299
- }
300
- """
301
-
302
- THEME = gr.themes.Default(primary_hue="teal", neutral_hue="slate").set(
303
- body_background_fill="#F4F7F9",
304
- body_background_fill_dark="#F4F7F9",
305
- block_background_fill="#FFFFFF",
306
- block_background_fill_dark="#FFFFFF",
307
- block_label_text_color="#1B2430",
308
- block_label_text_color_dark="#1B2430",
309
- body_text_color="#1B2430",
310
- body_text_color_dark="#1B2430",
311
- border_color_primary="#E1E8ED",
312
- border_color_primary_dark="#E1E8ED",
313
- checkbox_label_background_fill="#FFFFFF",
314
- checkbox_label_background_fill_dark="#FFFFFF",
315
- checkbox_label_background_fill_selected="#E7F6F4",
316
- checkbox_label_background_fill_selected_dark="#E7F6F4",
317
- checkbox_label_text_color="#1B2430",
318
- checkbox_label_text_color_dark="#1B2430",
319
- checkbox_label_text_color_selected="#15516B",
320
- checkbox_label_text_color_selected_dark="#15516B",
321
- checkbox_background_color="#FFFFFF",
322
- checkbox_background_color_dark="#FFFFFF",
323
- checkbox_background_color_selected="#13A89E",
324
- checkbox_background_color_selected_dark="#13A89E",
325
- checkbox_border_color="#61707E",
326
- checkbox_border_color_dark="#61707E",
327
- )
328
 
329
  with gr.Blocks(css=CSS, js=FORCE_LIGHT, theme=THEME, title="BOS AI Training") as demo:
330
  logo_html = f'<img src="{LOGO_SRC}" alt="BOS">' if LOGO_SRC else ""
331
- gr.HTML(
332
- f"""
333
- <div class="masthead">
334
- <div class="brand">
335
- {logo_html}
336
- <div class="divider"></div>
337
- <div>
338
- <h1>AI Training Platform</h1>
339
- <p class="tag">Six modules with a knowledge check at the end of each, plus a tools by team reference.</p>
340
- </div>
341
  </div>
342
- <div class="tool">Company standard: <b>Claude Enterprise</b></div>
343
- </div>
344
- """
345
- )
346
- bar = gr.HTML(track(0))
347
- state = gr.State(0)
348
 
349
  with gr.Row(equal_height=False):
350
  with gr.Column(scale=3, min_width=250, elem_classes=["rail"]):
351
  gr.HTML('<div class="rail-head">Learning path</div>')
352
- rail_btns = [
353
- gr.Button(f"{i + 1}. {m['title']}", variant="primary" if i == 0 else "secondary")
354
- for i, m in enumerate(MODULES)
355
- ]
356
  gr.HTML('<div class="rail-sub">Reference</div>')
357
- rail_btns.append(gr.Button("Tools by team", variant="secondary"))
 
358
 
359
  with gr.Column(scale=8):
360
- with gr.Column(visible=True) as module_view:
361
- content = gr.HTML(render_module(0))
 
 
 
362
  with gr.Column(elem_classes=["quiz"]):
363
- gr.HTML('<div class="qhead"><h3>Knowledge check</h3>'
364
- "<p>Pick one answer per question, then check. Anything you miss shows the correct answer and why.</p></div>")
365
  radios = [gr.Radio(choices=[], label="", visible=False) for _ in range(MAXQ)]
366
  check_btn = gr.Button("Check answers", elem_classes=["check"])
367
  result = gr.HTML("")
@@ -375,16 +534,18 @@ with gr.Blocks(css=CSS, js=FORCE_LIGHT, theme=THEME, title="BOS AI Training") as
375
 
376
  with gr.Row(elem_classes=["navrow"]):
377
  prev_btn = gr.Button("Previous module", variant="secondary", interactive=False)
378
- progress = gr.HTML(progress_text(0))
379
- next_btn = gr.Button("Next module", variant="primary")
380
 
381
- outputs = [state, module_view, team_view, content, bar, progress, prev_btn, next_btn, result, *rail_btns, *radios]
 
382
  for i, b in enumerate(rail_btns):
383
- b.click(lambda i=i: show(i), outputs=outputs)
384
- prev_btn.click(lambda s: show(s - 1), inputs=state, outputs=outputs)
385
- next_btn.click(lambda s: show(s + 1), inputs=state, outputs=outputs)
386
- check_btn.click(grade, inputs=[state, *radios], outputs=result)
387
- demo.load(lambda: show(0), outputs=outputs)
 
388
 
389
  if __name__ == "__main__":
390
  demo.launch()
 
3
  A guided, module based course on using AI at BOS Security, delivered as a
4
  HuggingFace Gradio Space.
5
 
6
+ Content: modules.py (lessons and takeaways), quizzes.py (knowledge checks),
7
+ teams.py (tools by team), figures.py (SVG diagrams). Logo: logo.png.
8
+
9
+ Pages: 0 = Overview, 1..N = modules, N+1 = Tools by team.
10
+ Progress is kept per browser session (gr.State), not stored server side.
11
  """
12
 
13
  import base64
14
  import html
15
+ import re
16
  from pathlib import Path
17
 
18
  import gradio as gr
 
29
  INK = "#1B2430"
30
  MUTED = "#61707E"
31
  PAPER = "#FFFFFF"
32
+ WASH = "#F3F6F9"
33
  RULE = "#E1E8ED"
34
  MISS = "#C0563A"
35
 
 
39
  LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
40
 
41
  N = len(MODULES)
42
+ HOME, TEAM_PAGE = 0, N + 1
43
  MAXQ = max(len(q) for q in QUIZZES)
44
+ TOTAL_Q = sum(len(q) for q in QUIZZES)
45
+
46
+
47
+ def _minutes(m):
48
+ words = sum(len(re.sub(r"<[^>]+>", " ", s["body"]).split()) for s in m["sections"])
49
+ return max(3, round(words / 200) + 1)
50
+
51
+
52
+ MINUTES = [_minutes(m) for m in MODULES]
53
 
54
  CSS = f"""
55
+ :root {{ --blue: {BLUE}; --blue-deep: {BLUE_DEEP}; --teal: {TEAL}; --teal-deep: {TEAL_DEEP}; --ink: {INK}; --muted: {MUTED};
56
+ --paper: {PAPER}; --wash: {WASH}; --rule: {RULE}; --miss: {MISS};
57
+ --shadow: 0 1px 2px rgba(14,58,77,.05), 0 8px 24px -12px rgba(14,58,77,.18); }}
58
  * {{ font-family: Arial, Helvetica, sans-serif !important; }}
59
  body, .gradio-container {{ background: var(--wash) !important; color: var(--ink); }}
60
+ .gradio-container {{ max-width: 1320px !important; margin: 0 auto !important; padding: 18px 22px 64px !important; }}
61
  footer {{ display: none !important; }}
62
+ .gradio-container .block, .gradio-container .form {{ box-shadow: none; }}
63
+ @keyframes rise {{ from {{ opacity: 0; transform: translateY(8px); }} to {{ opacity: 1; transform: none; }} }}
64
+ @media (prefers-reduced-motion: reduce) {{ *, *::before, *::after {{ animation: none !important; transition: none !important; }} }}
65
+
66
+ /* Top bar */
67
+ .topbar {{ display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap;
68
+ background: var(--paper); border: 1px solid var(--rule); border-radius: 16px; padding: 14px 22px; box-shadow: var(--shadow); }}
69
+ .topbar .brand {{ display: flex; align-items: center; gap: 18px; }}
70
+ .topbar img {{ height: 46px; width: auto; display: block; }}
71
+ .topbar .divider {{ width: 1px; height: 38px; background: var(--rule); }}
72
+ .topbar h1 {{ margin: 0; font-size: 19px; font-weight: 700; color: var(--blue); letter-spacing: -0.01em; }}
73
+ .topbar .tag {{ margin: 2px 0 0; font-size: 13px; color: var(--muted); }}
74
+ .topbar .right {{ display: flex; align-items: center; gap: 12px; }}
75
+ .topbar .tool {{ font-size: 13px; color: var(--muted); padding: 8px 14px; border: 1px solid var(--rule); border-radius: 999px; background: var(--wash); }}
76
+ .topbar .tool b {{ color: var(--blue); }}
77
+ .rail .pct {{ margin: 12px 4px 0; text-align: center; font-size: 13px; font-weight: 700; color: var(--teal-deep); padding: 9px 14px; border-radius: 10px; background: #E7F6F4; }}
78
+ .track {{ height: 5px; background: var(--rule); border-radius: 999px; overflow: hidden; margin: 12px 6px 6px; }}
79
+ .track div {{ height: 100%; background: linear-gradient(90deg, var(--teal), var(--teal-deep)); border-radius: 999px; transition: width .45s cubic-bezier(.2,.8,.2,1); }}
80
 
81
  /* Rail */
82
+ .rail {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 16px !important; box-shadow: var(--shadow);
83
+ padding: 12px 10px 14px !important; position: sticky; top: 16px; align-self: flex-start; gap: 3px !important; }}
84
+ .rail .rail-head {{ padding: 6px 12px 10px; font-size: 12.5px; color: var(--muted); }}
85
+ .rail .rail-sub {{ padding: 14px 12px 6px; font-size: 12.5px; color: var(--muted); border-top: 1px solid var(--rule); margin-top: 8px; }}
86
+ .rail button {{ width: 100%; justify-content: flex-start !important; text-align: left !important; padding: 11px 12px !important; margin: 0 !important;
87
+ border-radius: 10px !important; font-size: 14.5px !important; line-height: 1.35 !important; font-weight: 400 !important; white-space: normal !important;
88
+ transition: background .15s ease, color .15s ease, transform .1s ease; }}
 
89
  .rail button.secondary {{ background: transparent !important; border: 1px solid transparent !important; color: var(--ink) !important; box-shadow: none !important; }}
90
  .rail button.secondary:hover {{ background: var(--wash) !important; }}
91
+ .rail button.primary {{ background: #E7F6F4 !important; border: 1px solid #C6EBE7 !important; color: var(--blue) !important; font-weight: 700 !important; box-shadow: none !important; }}
92
+ .rail button.stop {{ background: transparent !important; border: 1px solid transparent !important; color: var(--teal-deep) !important; box-shadow: none !important; }}
93
+ .rail button.stop:hover {{ background: var(--wash) !important; }}
94
  .rail button:active {{ transform: scale(.985); }}
95
  .rail button:focus-visible {{ outline: 2px solid var(--teal) !important; outline-offset: 2px; }}
96
 
97
+ /* Cards shared */
98
+ .card {{ background: var(--paper); border: 1px solid var(--rule); border-radius: 16px; box-shadow: var(--shadow); animation: rise .3s ease both; }}
99
+
100
+ /* Overview */
101
+ .hero {{ display: grid; grid-template-columns: 1fr 220px; gap: 32px; align-items: center; padding: 44px 52px;
102
+ background: linear-gradient(135deg, var(--blue-deep) 0%, var(--blue) 55%, #17667f 100%); color: #fff; border-color: transparent; }}
103
+ .hero .eyebrow {{ font-size: 13px; color: #A9D8D3; font-weight: 700; margin: 0 0 10px; }}
104
+ .hero h2 {{ font-size: 40px; line-height: 1.08; margin: 0 0 14px; letter-spacing: -0.02em; color: #fff; }}
105
+ .hero p {{ font-size: 17px; line-height: 1.55; color: #D5E3EA; margin: 0 0 22px; max-width: 58ch; }}
106
+ .hero .stats {{ display: flex; gap: 26px; flex-wrap: wrap; }}
107
+ .hero .stat b {{ display: block; font-size: 22px; color: #fff; }}
108
+ .hero .stat span {{ font-size: 13px; color: #A9C4D0; }}
109
+ .hero .ring {{ display: flex; flex-direction: column; align-items: center; gap: 10px; }}
110
+ .hero .ring svg {{ width: 160px; height: 160px; }}
111
+ .hero .ring .label {{ font-size: 13px; color: #A9D8D3; }}
112
+ .resume {{ display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px 24px; margin-top: 16px; border-left: 5px solid var(--teal); }}
113
+ .resume .t {{ font-size: 13px; color: var(--muted); margin: 0 0 3px; }}
114
+ .resume .n {{ font-size: 18px; font-weight: 700; color: var(--blue); margin: 0; }}
115
+ .resume button {{ background: var(--teal); color: #fff; border: none; border-radius: 10px; padding: 12px 20px; font-weight: 700; font-size: 14px; cursor: pointer; white-space: nowrap; transition: background .15s ease, transform .1s ease; }}
116
+ .resume button:hover {{ background: var(--teal-deep); }}
117
+ .resume button:active {{ transform: scale(.98); }}
118
+ .grid-head {{ display: flex; align-items: baseline; justify-content: space-between; margin: 30px 4px 14px; }}
119
+ .grid-head h3 {{ font-size: 20px; color: var(--blue); margin: 0; }}
120
+ .grid-head span {{ font-size: 13.5px; color: var(--muted); }}
121
+ .mgrid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }}
122
+ .mcard {{ position: relative; display: flex; flex-direction: column; gap: 10px; padding: 22px 22px 20px; background: var(--paper); border: 1px solid var(--rule);
123
+ border-radius: 16px; box-shadow: var(--shadow); cursor: pointer; text-align: left; font: inherit; color: var(--ink);
124
+ transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; animation: rise .3s ease both; }}
125
+ .mcard:hover {{ transform: translateY(-3px); border-color: #BFE3DF; box-shadow: 0 2px 4px rgba(14,58,77,.05), 0 16px 32px -14px rgba(14,58,77,.25); }}
126
+ .mcard:focus-visible {{ outline: 2px solid var(--teal); outline-offset: 2px; }}
127
+ .mcard .row {{ display: flex; align-items: center; justify-content: space-between; }}
128
+ .mcard .num {{ width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: var(--blue); color: #fff; font-weight: 700; font-size: 15px; }}
129
+ .mcard.done .num {{ background: var(--teal); }}
130
+ .mcard .status {{ font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; background: var(--wash); color: var(--muted); }}
131
+ .mcard.done .status {{ background: #E7F6F4; color: var(--teal-deep); }}
132
+ .mcard.required .status.req {{ background: var(--blue); color: #fff; }}
133
+ .mcard h4 {{ margin: 6px 0 0; font-size: 18px; line-height: 1.25; color: var(--blue); letter-spacing: -0.01em; }}
134
+ .mcard p {{ margin: 0; font-size: 14.5px; line-height: 1.5; color: var(--muted); flex: 1; }}
135
+ .mcard .foot {{ display: flex; justify-content: space-between; font-size: 12.5px; color: var(--muted); padding-top: 12px; border-top: 1px solid var(--rule); }}
136
+ .mcard .foot b {{ color: var(--teal-deep); font-weight: 700; }}
137
+ .mcard.ref {{ background: linear-gradient(180deg, #F7FBFB, #EEF7F6); }}
138
+
139
+ /* Reader */
140
+ .reader {{ padding: 46px 64px 38px; }}
141
+ .reader .meta {{ display: flex; align-items: center; gap: 12px; margin-bottom: 16px; font-size: 13px; color: var(--muted); }}
142
+ .pill {{ display: inline-block; padding: 4px 11px; border-radius: 999px; font-weight: 700; font-size: 12px; background: #E7F6F4; color: var(--teal-deep); }}
143
+ .pill.required {{ background: var(--blue); color: #fff; }}
144
+ .pill.done {{ background: var(--teal); color: #fff; }}
145
+ .reader h2 {{ font-size: 36px; line-height: 1.1; color: var(--blue); margin: 0 0 12px; letter-spacing: -0.02em; }}
146
+ .reader .summary {{ font-size: 18.5px; color: var(--muted); margin: 0 0 34px; padding-bottom: 26px; border-bottom: 1px solid var(--rule); max-width: 62ch; line-height: 1.45; }}
147
+ .reader h3 {{ font-size: 21px; color: var(--blue); margin: 38px 0 12px; letter-spacing: -0.01em; }}
148
  .reader h3:first-of-type {{ margin-top: 0; }}
149
+ .reader p {{ font-size: 17px; line-height: 1.68; margin: 0 0 15px; max-width: 68ch; color: var(--ink); }}
150
  .reader p b {{ color: var(--blue); }}
151
+ .fig {{ margin: 24px 0 30px; padding: 24px 26px 16px; background: var(--wash); border: 1px solid var(--rule); border-radius: 14px; max-width: 760px; }}
152
+ .fig svg {{ width: 100%; height: auto; display: block; }}
153
+ .fig figcaption {{ margin-top: 12px; font-size: 13.5px; color: var(--muted); line-height: 1.45; }}
154
+ .takeaways {{ margin-top: 44px; padding: 26px 30px; border-radius: 14px; background: linear-gradient(135deg, #EEF7F6, #F7FBFB); border: 1px solid #CFEAE7; max-width: 760px; }}
155
+ .takeaways h3 {{ margin: 0 0 14px !important; font-size: 17px !important; color: var(--teal-deep) !important; }}
156
+ .takeaways ol {{ margin: 0; padding-left: 0; list-style: none; counter-reset: t; }}
157
+ .takeaways li {{ counter-increment: t; display: grid; grid-template-columns: 30px 1fr; gap: 10px; align-items: start; font-size: 16px; line-height: 1.55; margin-bottom: 10px; color: var(--ink); }}
158
+ .takeaways li::before {{ content: counter(t); width: 26px; height: 26px; border-radius: 8px; background: var(--teal); color: #fff; font-weight: 700; font-size: 13px; display: grid; place-items: center; margin-top: 1px; }}
159
+ .help {{ margin-top: 22px; padding: 18px 22px; background: var(--wash); border-radius: 12px; border-left: 4px solid var(--blue); max-width: 760px; }}
160
+ .help p {{ margin: 0; max-width: none; font-size: 15px; }}
161
+ .help p b {{ color: var(--blue); }}
162
 
163
  /* Quiz */
164
+ .quiz {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 16px !important; box-shadow: var(--shadow);
165
+ padding: 30px 40px 28px !important; margin-top: 16px; gap: 4px !important; }}
166
+ .quiz .qhead {{ display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 8px; }}
167
+ .quiz .qhead h3 {{ font-size: 21px; color: var(--blue); margin: 0; }}
168
+ .quiz .qhead span {{ font-size: 13px; color: var(--muted); }}
169
+ .quiz .qsub {{ color: var(--muted); font-size: 15px; margin: 0 0 8px; max-width: 70ch; }}
170
+ .quiz, .quiz .block, .quiz .form, .quiz fieldset, .quiz .wrap {{ background: var(--paper) !important; color: var(--ink) !important; }}
171
+ .quiz .block, .quiz .form {{ border: none !important; box-shadow: none !important; padding: 12px 0 6px !important; margin: 0 !important; }}
172
  .quiz fieldset {{ border: none !important; padding: 0 !important; margin: 0 !important; }}
173
+ .quiz span[data-testid="block-info"], .quiz .block > label > span, .quiz legend {{ font-size: 16.5px !important; font-weight: 700 !important; color: var(--ink) !important;
174
+ background: transparent !important; margin-bottom: 10px !important; display: block; line-height: 1.4; }}
175
+ .quiz .wrap {{ display: flex; flex-direction: column; gap: 7px !important; }}
176
+ .quiz .wrap label {{ display: flex !important; align-items: center; gap: 12px; padding: 12px 15px !important; border: 1px solid var(--rule) !important; border-radius: 10px !important;
177
+ background: var(--paper) !important; cursor: pointer; font-size: 15px; box-shadow: none !important; transition: border-color .12s ease, background .12s ease, transform .08s ease; }}
178
+ .quiz .wrap label:hover {{ border-color: var(--teal) !important; background: #FAFDFD !important; }}
179
+ .quiz .wrap label:active {{ transform: scale(.995); }}
180
+ .quiz .wrap label.selected {{ border-color: var(--teal) !important; background: #E7F6F4 !important; }}
181
+ .quiz .wrap label, .quiz .wrap label span, .quiz .wrap label * {{ color: var(--ink) !important; }}
182
+ .quiz .wrap label.selected span {{ color: var(--blue) !important; font-weight: 700; }}
183
+ .quiz .wrap label input[type="radio"] {{ appearance: auto !important; -webkit-appearance: radio !important; width: 18px; height: 18px; margin: 0; accent-color: var(--teal);
184
+ background: #fff !important; border: 1px solid var(--muted) !important; border-radius: 50% !important; box-shadow: none !important; }}
185
+ .quiz .check {{ background: var(--blue) !important; border: 1px solid var(--blue) !important; color: #fff !important; box-shadow: none !important; border-radius: 10px !important;
186
+ font-weight: 700 !important; padding: 13px 24px !important; margin-top: 10px; width: auto; transition: background .15s ease, transform .1s ease; }}
187
  .quiz .check:hover {{ background: var(--blue-deep) !important; }}
188
+ .quiz .check:active {{ transform: scale(.98); }}
189
+ .result {{ margin-top: 14px; animation: rise .25s ease both; }}
190
+ .result .scorecard {{ display: flex; align-items: center; gap: 18px; padding: 16px 20px; border-radius: 12px; background: var(--wash); margin-bottom: 14px; }}
191
+ .result .scorecard svg {{ width: 64px; height: 64px; flex: none; }}
192
+ .result .scorecard .s1 {{ font-size: 18px; font-weight: 700; color: var(--blue); margin: 0 0 2px; }}
193
+ .result .scorecard .s2 {{ font-size: 14px; color: var(--muted); margin: 0; }}
194
  .result .item {{ padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 15px; line-height: 1.5; }}
195
  .result .item.ok {{ background: #E7F6F4; border-left: 4px solid var(--teal); }}
196
  .result .item.miss {{ background: #FBEFEA; border-left: 4px solid var(--miss); }}
 
200
  .result .warn {{ color: var(--miss); font-weight: 700; }}
201
 
202
  /* Tools by team */
203
+ .teamwrap .tabs {{ margin-top: 14px; background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 16px !important; box-shadow: var(--shadow); padding: 10px 44px 34px !important; }}
204
+ .teamwrap .tabs button {{ font-size: 15px !important; font-weight: 700 !important; color: var(--muted) !important; border: none !important; border-bottom: 3px solid transparent !important;
205
+ border-radius: 0 !important; padding: 14px 18px !important; background: transparent !important; box-shadow: none !important; }}
 
206
  .teamwrap .tabs button.selected {{ color: var(--blue) !important; border-bottom-color: var(--teal) !important; }}
 
 
 
 
 
207
  .teamwrap .tabitem {{ border: none !important; padding: 4px 0 0 !important; background: transparent !important; }}
208
  .teamwrap .tabitem h3 {{ font-size: 19px; color: var(--blue); margin: 26px 0 8px; }}
209
+ .teamwrap .tabitem h3:first-child {{ margin-top: 12px; }}
210
+ .teamwrap .tabitem p {{ font-size: 16.5px; line-height: 1.65; margin: 0 0 12px; max-width: 72ch; color: var(--ink); }}
211
  .teamwrap .tabitem p b {{ color: var(--blue); }}
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  /* Nav */
214
  .navrow {{ margin-top: 14px; align-items: center; }}
215
+ .navrow button {{ border-radius: 10px !important; font-weight: 700 !important; font-size: 14px !important; padding: 13px 22px !important; transition: background .15s ease, transform .1s ease; }}
 
216
  .navrow button:active {{ transform: scale(.98); }}
217
  .navrow .primary {{ background: var(--teal) !important; border: 1px solid var(--teal) !important; color: #fff !important; box-shadow: none !important; }}
218
  .navrow .primary:hover {{ background: var(--teal-deep) !important; }}
 
220
  .navrow .secondary:hover {{ background: var(--wash) !important; }}
221
  .progress {{ text-align: center; color: var(--muted); font-size: 14px; }}
222
 
223
+ @media (max-width: 1000px) {{ .mgrid {{ grid-template-columns: repeat(2, 1fr); }} .hero {{ grid-template-columns: 1fr; padding: 32px; }} .hero .ring {{ display: none; }} }}
224
+ @media (max-width: 720px) {{
225
+ .rail {{ position: static; }} .mgrid {{ grid-template-columns: 1fr; }}
226
+ .reader {{ padding: 28px 22px; }} .quiz {{ padding: 22px 20px !important; }} .teamwrap .tabs {{ padding: 8px 18px 24px !important; }}
227
+ .reader h2 {{ font-size: 28px; }} .hero h2 {{ font-size: 30px; }} .topbar .divider {{ display: none; }}
 
228
  }}
229
  """
230
 
231
+ FORCE_LIGHT = """
232
+ () => {
233
+ const strip = () => {
234
+ document.body.classList.remove('dark');
235
+ document.documentElement.classList.remove('dark');
236
+ document.querySelectorAll('.dark').forEach(el => el.classList.remove('dark'));
237
+ };
238
+ strip();
239
+ new MutationObserver(strip).observe(document.body, { attributes: true, attributeFilter: ['class'] });
240
+ }
241
+ """
242
+
243
+ THEME = gr.themes.Default(primary_hue="teal", neutral_hue="slate").set(
244
+ body_background_fill=WASH, body_background_fill_dark=WASH,
245
+ block_background_fill=PAPER, block_background_fill_dark=PAPER,
246
+ block_label_text_color=INK, block_label_text_color_dark=INK,
247
+ body_text_color=INK, body_text_color_dark=INK,
248
+ border_color_primary=RULE, border_color_primary_dark=RULE,
249
+ checkbox_label_background_fill=PAPER, checkbox_label_background_fill_dark=PAPER,
250
+ checkbox_label_background_fill_selected="#E7F6F4", checkbox_label_background_fill_selected_dark="#E7F6F4",
251
+ checkbox_label_text_color=INK, checkbox_label_text_color_dark=INK,
252
+ checkbox_label_text_color_selected=BLUE, checkbox_label_text_color_selected_dark=BLUE,
253
+ checkbox_background_color=PAPER, checkbox_background_color_dark=PAPER,
254
+ checkbox_background_color_selected=TEAL, checkbox_background_color_selected_dark=TEAL,
255
+ checkbox_border_color=MUTED, checkbox_border_color_dark=MUTED,
256
+ )
257
+
258
 
259
  # Rendering helpers
260
 
261
+ def ring(pct: int, size=160, stroke=12, track="rgba(255,255,255,.18)", color=TEAL, text_color="#fff", sub=""):
262
+ r = (size - stroke) / 2
263
+ c = 2 * 3.14159 * r
264
+ dash = c * pct / 100
265
+ sub_el = f'<text x="50%" y="66%" text-anchor="middle" font-size="{size*0.085}" fill="{text_color}" opacity=".75" font-family="Arial">{html.escape(sub)}</text>' if sub else ""
266
+ return (
267
+ f'<svg viewBox="0 0 {size} {size}" role="img" aria-label="{pct} percent complete">'
268
+ f'<circle cx="{size/2}" cy="{size/2}" r="{r}" fill="none" stroke="{track}" stroke-width="{stroke}"/>'
269
+ f'<circle cx="{size/2}" cy="{size/2}" r="{r}" fill="none" stroke="{color}" stroke-width="{stroke}" stroke-linecap="round" '
270
+ f'stroke-dasharray="{dash:.1f} {c:.1f}" transform="rotate(-90 {size/2} {size/2})" style="transition:stroke-dasharray .6s ease"/>'
271
+ f'<text x="50%" y="{"52%" if sub else "50%"}" dominant-baseline="middle" text-anchor="middle" font-size="{size*0.21}" font-weight="700" fill="{text_color}" font-family="Arial">{pct}%</text>'
272
+ f"{sub_el}</svg>"
273
+ )
274
+
275
+
276
+ def pct_done(progress: dict) -> int:
277
+ return round(100 * len(progress) / N)
278
+
279
+
280
+ def figure(svg_markup: str, caption: str) -> str:
281
+ return f'<figure class="fig">{svg_markup}<figcaption>{html.escape(caption)}</figcaption></figure>'
282
+
283
+
284
+ def render_home(progress: dict) -> str:
285
+ done = len(progress)
286
+ pct = pct_done(progress)
287
+ next_idx = next((i for i in range(N) if i not in progress), None)
288
+ total_min = sum(MINUTES)
289
+ parts = [f"""
290
+ <div class="card hero">
291
+ <div>
292
+ <p class="eyebrow">BOS Security company training</p>
293
+ <h2>Using AI well, and safely, at BOS</h2>
294
+ <p>Six short modules that build on each other: what AI is, how it works, how to get real results from Claude Enterprise, and where the hard lines on client and company data sit. Each module ends with a knowledge check.</p>
295
+ <div class="stats">
296
+ <div class="stat"><b>{N}</b><span>modules</span></div>
297
+ <div class="stat"><b>{total_min} min</b><span>total reading</span></div>
298
+ <div class="stat"><b>{TOTAL_Q}</b><span>check questions</span></div>
299
+ <div class="stat"><b>{done} of {N}</b><span>completed</span></div>
300
+ </div>
301
+ </div>
302
+ <div class="ring">{ring(pct, sub="complete")}<span class="label">Progress this session</span></div>
303
+ </div>"""]
304
+ if next_idx is not None:
305
+ m = MODULES[next_idx]
306
+ label = "Start here" if done == 0 else "Continue where you left off"
307
+ parts.append(f"""
308
+ <div class="card resume">
309
+ <div><p class="t">{label}</p><p class="n">Module {next_idx + 1}. {html.escape(m['title'])}</p></div>
310
+ <button type="button" onclick="document.getElementById('rail-{next_idx + 1}').click()">{'Begin module' if done == 0 else 'Resume'}</button>
311
+ </div>""")
312
+ else:
313
+ parts.append("""
314
+ <div class="card resume">
315
+ <div><p class="t">All modules completed</p><p class="n">You have completed the BOS AI training path. Keep the Tools by team page handy.</p></div>
316
+ <button type="button" onclick="document.getElementById('rail-team').click()">Open Tools by team</button>
317
+ </div>""")
318
+ parts.append(f'<div class="grid-head"><h3>Learning path</h3><span>Read in order. Each module builds on the one before it.</span></div><div class="mgrid">')
319
+ for i, m in enumerate(MODULES):
320
+ is_done = i in progress
321
+ req = m["depth"].lower() == "required"
322
+ cls = "mcard" + (" done" if is_done else "") + (" required" if req else "")
323
+ if is_done:
324
+ status = f'<span class="status">Scored {progress[i][0]} of {progress[i][1]}</span>'
325
+ elif req:
326
+ status = '<span class="status req">Required</span>'
327
+ else:
328
+ status = f'<span class="status">{html.escape(m["depth"])}</span>'
329
+ parts.append(f"""
330
+ <button type="button" class="{cls}" style="animation-delay:{i*40}ms" onclick="document.getElementById('rail-{i + 1}').click()">
331
+ <div class="row"><span class="num">{'&#10003;' if is_done else i + 1}</span>{status}</div>
332
+ <h4>{html.escape(m['title'])}</h4>
333
+ <p>{html.escape(m['summary'])}</p>
334
+ <div class="foot"><span>{MINUTES[i]} min read</span><b>{len(QUIZZES[i])} questions</b></div>
335
+ </button>""")
336
+ parts.append(f"""
337
+ <button type="button" class="mcard ref" style="animation-delay:{N*40}ms" onclick="document.getElementById('rail-team').click()">
338
+ <div class="row"><span class="num" style="background:var(--teal-deep)">&#9776;</span><span class="status">Reference</span></div>
339
+ <h4>Tools by team</h4>
340
+ <p>Where Claude fits next to Zoho, Apollo, and Microsoft 365 for Staffing, Security, Sales, and VirtuGuard.</p>
341
+ <div class="foot"><span>4 teams</span><b>Open any time</b></div>
342
+ </button></div>""")
343
+ return "\n".join(parts)
344
+
345
+
346
+ def render_module(idx: int, progress: dict) -> str:
347
  m = MODULES[idx]
348
+ req = m["depth"].lower() == "required"
349
+ pills = f'<span class="pill{" required" if req else ""}">{html.escape(m["depth"])}</span>'
350
+ if idx in progress:
351
+ pills += f'<span class="pill done">Scored {progress[idx][0]} of {progress[idx][1]}</span>'
352
  parts = [
353
+ '<div class="card reader">',
354
+ f'<div class="meta">{pills}<span>Module {idx + 1} of {N}</span><span>{MINUTES[idx]} min read</span></div>',
 
355
  f'<h2>{html.escape(m["title"])}</h2>',
356
  f'<p class="summary">{html.escape(m["summary"])}</p>',
357
  ]
 
361
  fig = FIGURES.get((idx, s["heading"]))
362
  if fig:
363
  parts.append(figure(*fig))
364
+ parts.append('<div class="takeaways"><h3>Key takeaways</h3><ol>' + "".join(f"<li>{html.escape(t)}</li>" for t in m["takeaways"]) + "</ol></div>")
365
  parts.append(f'<div class="help"><p><b>Need help?</b> {html.escape(IT_HELP)}</p></div>')
366
  parts.append("</div>")
367
  return "\n".join(parts)
368
 
369
 
 
 
 
 
370
  def render_team_header() -> str:
371
  return (
372
+ '<div class="card reader" style="padding-bottom:22px">'
373
  '<div class="meta"><span class="pill">Reference</span><span>Tools by team</span></div>'
374
  "<h2>Where Claude fits next to Zoho, Apollo, and Microsoft 365</h2>"
375
  f"{TEAMS_INTRO}{figure(*TEAM_FIGURE)}</div>"
376
  )
377
 
378
 
379
+ def track(idx: int, progress: dict) -> str:
380
+ return f'<div class="track"><div style="width:{pct_done(progress)}%"></div></div>'
381
+
382
+
383
+ def pct_badge(progress: dict) -> str:
384
+ return f'<div class="pct">{pct_done(progress)}% complete</div>'
385
+
386
 
387
+ def progress_text(page: int) -> str:
388
+ if page == HOME:
389
+ return '<div class="progress">Overview</div>'
390
+ if page == TEAM_PAGE:
391
+ return '<div class="progress">Reference</div>'
392
+ return f'<div class="progress">Module {page} of {N}</div>'
393
 
 
 
394
 
395
+ def rail_labels(progress: dict):
396
+ labels = ["Overview"]
397
+ for i, m in enumerate(MODULES):
398
+ mark = "\u2713 " if i in progress else ""
399
+ labels.append(f"{mark}{i + 1}. {m['title']}")
400
+ labels.append("Tools by team")
401
+ return labels
402
 
 
 
403
 
404
+ def rail_updates(page: int, progress: dict):
405
+ labels = rail_labels(progress)
406
+ out = []
407
+ for i, lab in enumerate(labels):
408
+ if i == page:
409
+ variant = "primary"
410
+ elif 1 <= i <= N and (i - 1) in progress:
411
+ variant = "stop"
412
+ else:
413
+ variant = "secondary"
414
+ out.append(gr.update(value=lab, variant=variant))
415
+ return out
416
 
417
+
418
+ def quiz_updates(page: int):
419
+ qs = QUIZZES[page - 1] if 1 <= page <= N else []
420
  out = []
421
  for i in range(MAXQ):
422
  if i < len(qs):
 
426
  return out
427
 
428
 
429
+ def quiz_head(page: int) -> str:
430
+ n = len(QUIZZES[page - 1]) if 1 <= page <= N else 0
431
+ return (f'<div class="qhead"><h3>Knowledge check</h3><span>{n} questions</span></div>'
432
+ '<p class="qsub">Pick one answer per question, then check. Anything you miss shows the correct answer and why. '
433
+ 'Checking your answers marks the module complete for this session; you can retake it any time.</p>')
434
+
435
+
436
+ def show(page: int, progress: dict):
437
+ page = max(HOME, min(TEAM_PAGE, page))
438
+ on_home, on_team = page == HOME, page == TEAM_PAGE
439
+ on_module = not on_home and not on_team
440
+ next_label = "Start module 1" if on_home else ("Tools by team" if page == N else "Next module")
441
+ return [
442
+ page,
443
+ gr.update(visible=on_home),
444
+ gr.update(visible=on_module),
445
+ gr.update(visible=on_team),
446
+ render_home(progress) if on_home else "",
447
+ render_module(page - 1, progress) if on_module else "",
448
+ track(page, progress),
449
+ pct_badge(progress),
450
+ progress_text(page),
451
+ gr.update(interactive=page > HOME, value="Overview" if page == 1 else "Previous module"),
452
+ gr.update(interactive=page < TEAM_PAGE, value=next_label),
453
+ quiz_head(page),
454
+ "",
455
+ *rail_updates(page, progress),
456
+ *quiz_updates(page),
457
+ ]
458
+
459
+
460
+ def grade(page: int, progress: dict, *answers):
461
+ idx = page - 1
462
  qs = QUIZZES[idx]
463
  unanswered = [i + 1 for i, a in enumerate(answers[: len(qs)]) if a is None]
464
  if unanswered:
465
  nums = ", ".join(str(n) for n in unanswered)
466
+ word = "question" if len(unanswered) == 1 else "questions"
467
+ return [progress, f'<div class="result"><p class="warn">Answer {word} {nums} before checking.</p></div>',
468
+ gr.update(), gr.update(), gr.update(), *[gr.update() for _ in range(N + 2)]]
469
+ items, correct = [], 0
470
  for i, q in enumerate(qs):
471
+ chosen, right = answers[i], q["options"][q["answer"]]
 
472
  if chosen == right:
473
  correct += 1
474
+ items.append(f'<div class="item ok"><p><b>Question {i + 1}.</b> Correct.</p><p>{html.escape(right)}</p></div>')
 
 
 
475
  else:
476
+ items.append(f'<div class="item miss"><p><b>Question {i + 1}.</b> Not quite. You chose: {html.escape(chosen)}</p>'
477
+ f'<p><b>Correct answer:</b> {html.escape(right)}</p><p>{html.escape(q["why"])}</p></div>')
478
+ pct = round(100 * correct / len(qs))
479
+ progress = dict(progress)
480
+ progress[idx] = (correct, len(qs))
 
481
  if correct == len(qs):
482
+ s1, s2 = f"All {len(qs)} correct.", "Module marked complete. Move on when you are ready."
483
+ else:
484
+ s1, s2 = f"{correct} of {len(qs)} right.", "Read the explanations below, then move on. You can retake the check any time."
485
+ card = (f'<div class="scorecard">{ring(pct, size=64, stroke=7, track="#E1E8ED", color=TEAL if pct == 100 else BLUE, text_color=BLUE)}'
486
+ f'<div><p class="s1">{s1}</p><p class="s2">{s2}</p></div></div>')
487
+ result = f'<div class="result">{card}{"".join(items)}</div>'
488
+ return [progress, result, track(page, progress), pct_badge(progress),
489
+ render_module(idx, progress), *rail_updates(page, progress)]
490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
 
492
  with gr.Blocks(css=CSS, js=FORCE_LIGHT, theme=THEME, title="BOS AI Training") as demo:
493
  logo_html = f'<img src="{LOGO_SRC}" alt="BOS">' if LOGO_SRC else ""
494
+ with gr.Row(elem_classes=["topbar-row"]):
495
+ topbar = gr.HTML(f"""
496
+ <div class="topbar">
497
+ <div class="brand">{logo_html}<div class="divider"></div>
498
+ <div><h1>AI Training Platform</h1><p class="tag">Six modules with knowledge checks, plus a tools by team reference.</p></div>
 
 
 
 
 
499
  </div>
500
+ <div class="right"><div class="tool">Company standard: <b>Claude Enterprise</b></div></div>
501
+ </div>""")
502
+ bar = gr.HTML(track(HOME, {}))
503
+ page_state = gr.State(HOME)
504
+ progress_state = gr.State({})
 
505
 
506
  with gr.Row(equal_height=False):
507
  with gr.Column(scale=3, min_width=250, elem_classes=["rail"]):
508
  gr.HTML('<div class="rail-head">Learning path</div>')
509
+ rail_btns = [gr.Button("Overview", variant="primary", elem_id="rail-0")]
510
+ for i, m in enumerate(MODULES):
511
+ rail_btns.append(gr.Button(f"{i + 1}. {m['title']}", variant="secondary", elem_id=f"rail-{i + 1}"))
 
512
  gr.HTML('<div class="rail-sub">Reference</div>')
513
+ rail_btns.append(gr.Button("Tools by team", variant="secondary", elem_id="rail-team"))
514
+ pct_html = gr.HTML(pct_badge({}))
515
 
516
  with gr.Column(scale=8):
517
+ with gr.Column(visible=True) as home_view:
518
+ home = gr.HTML(render_home({}))
519
+
520
+ with gr.Column(visible=False) as module_view:
521
+ content = gr.HTML("")
522
  with gr.Column(elem_classes=["quiz"]):
523
+ qhead = gr.HTML(quiz_head(1))
 
524
  radios = [gr.Radio(choices=[], label="", visible=False) for _ in range(MAXQ)]
525
  check_btn = gr.Button("Check answers", elem_classes=["check"])
526
  result = gr.HTML("")
 
534
 
535
  with gr.Row(elem_classes=["navrow"]):
536
  prev_btn = gr.Button("Previous module", variant="secondary", interactive=False)
537
+ progress_lbl = gr.HTML(progress_text(HOME))
538
+ next_btn = gr.Button("Start module 1", variant="primary")
539
 
540
+ outputs = [page_state, home_view, module_view, team_view, home, content, bar, pct_html, progress_lbl,
541
+ prev_btn, next_btn, qhead, result, *rail_btns, *radios]
542
  for i, b in enumerate(rail_btns):
543
+ b.click(lambda prog, i=i: show(i, prog), inputs=progress_state, outputs=outputs)
544
+ prev_btn.click(lambda p, prog: show(p - 1, prog), inputs=[page_state, progress_state], outputs=outputs)
545
+ next_btn.click(lambda p, prog: show(p + 1, prog), inputs=[page_state, progress_state], outputs=outputs)
546
+ check_btn.click(grade, inputs=[page_state, progress_state, *radios],
547
+ outputs=[progress_state, result, bar, pct_html, content, *rail_btns])
548
+ demo.load(lambda: show(HOME, {}), outputs=outputs)
549
 
550
  if __name__ == "__main__":
551
  demo.launch()
modules.py CHANGED
@@ -18,6 +18,11 @@ IT_HELP = (
18
  MODULES = [
19
  {
20
  "title": "AI at BOS: Start Here",
 
 
 
 
 
21
  "summary": "What AI is, what it is not, and why BOS standardized on one tool.",
22
  "depth": "Foundation",
23
  "sections": [
@@ -86,6 +91,11 @@ account, or AI features baked into browser extensions and phone apps.</p>
86
  },
87
  {
88
  "title": "How AI Actually Works",
 
 
 
 
 
89
  "summary": "Prediction, training data, context, and why the tool sometimes makes things up.",
90
  "depth": "Foundation",
91
  "sections": [
@@ -152,6 +162,11 @@ not the AI's opinion.</p>
152
  },
153
  {
154
  "title": "Your First Conversations",
 
 
 
 
 
155
  "summary": "Getting started in Claude Enterprise and the basics of asking well.",
156
  "depth": "Practice",
157
  "sections": [
@@ -210,6 +225,11 @@ conversation degrades results and makes it harder to find things later.</p>
210
  },
211
  {
212
  "title": "Getting Better Results",
 
 
 
 
 
213
  "summary": "Techniques that separate a casual user from someone who saves hours a week.",
214
  "depth": "Practice",
215
  "sections": [
@@ -295,6 +315,11 @@ items. Cut them.</p>
295
  },
296
  {
297
  "title": "Security and Data",
 
 
 
 
 
298
  "summary": "The hard lines. Required reading for every BOS employee.",
299
  "depth": "Required",
300
  "sections": [
@@ -393,6 +418,11 @@ to BOS IT.</p>
393
  },
394
  {
395
  "title": "Everyday Workflows and Getting Help",
 
 
 
 
 
396
  "summary": "What good use looks like in your role, and where to turn when you need help.",
397
  "depth": "Applied",
398
  "sections": [
 
18
  MODULES = [
19
  {
20
  "title": "AI at BOS: Start Here",
21
+ "takeaways": [
22
+ "Claude Enterprise is the one approved AI tool for BOS and client information.",
23
+ "A language model drafts, rewrites, summarizes, and reasons in plain language. It does not know BOS unless you tell it.",
24
+ "You carry the responsibility for anything the tool helps you produce.",
25
+ ],
26
  "summary": "What AI is, what it is not, and why BOS standardized on one tool.",
27
  "depth": "Foundation",
28
  "sections": [
 
91
  },
92
  {
93
  "title": "How AI Actually Works",
94
+ "takeaways": [
95
+ "The model predicts plausible text. It does not look facts up unless given a tool to do so.",
96
+ "Its working knowledge is the context: your messages, its replies, and attached files.",
97
+ "Every number, date, name, and compatibility claim gets verified against the real source.",
98
+ ],
99
  "summary": "Prediction, training data, context, and why the tool sometimes makes things up.",
100
  "depth": "Foundation",
101
  "sections": [
 
162
  },
163
  {
164
  "title": "Your First Conversations",
165
+ "takeaways": [
166
+ "Confirm you are in the BOS organization workspace before any work use.",
167
+ "Good requests carry context, specifics, material, and a next step.",
168
+ "The first answer is a draft. Iterate two or three times and keep your own voice.",
169
+ ],
170
  "summary": "Getting started in Claude Enterprise and the basics of asking well.",
171
  "depth": "Practice",
172
  "sections": [
 
225
  },
226
  {
227
  "title": "Getting Better Results",
228
+ "takeaways": [
229
+ "Give the model a role and a quality bar, and paste an example of work you were happy with.",
230
+ "Break big deliverables into an outline and sections. Ask it to answer only from attached documents.",
231
+ "Check with BOS IT before creating a shared project; one may already exist.",
232
+ ],
233
  "summary": "Techniques that separate a casual user from someone who saves hours a week.",
234
  "depth": "Practice",
235
  "sections": [
 
315
  },
316
  {
317
  "title": "Security and Data",
318
+ "takeaways": [
319
+ "CUI, FCI, export controlled material, credentials, personnel data, and raw incident material never go into any AI tool.",
320
+ "External material refers to clients generically unless attribution is approved in writing.",
321
+ "Legal notices, subpoenas, claims, regulatory inquiries, and incident disputes go to leadership, not to a draft.",
322
+ ],
323
  "summary": "The hard lines. Required reading for every BOS employee.",
324
  "depth": "Required",
325
  "sections": [
 
418
  },
419
  {
420
  "title": "Everyday Workflows and Getting Help",
421
+ "takeaways": [
422
+ "Each role has good uses and hard lines. Know yours.",
423
+ "Finished work reads like a person at BOS wrote it: prose, bold inline labels, no em dashes, no emojis.",
424
+ "Ask BOS IT before you paste, not after.",
425
+ ],
426
  "summary": "What good use looks like in your role, and where to turn when you need help.",
427
  "depth": "Applied",
428
  "sections": [