Spaces:
Sleeping
Sleeping
Download app.py from VirtuGuard/BOS_AI: direct link, hf CLI and curl.
- Browser
- Download file 37.4 kB
-
https://huggingface.co/spaces/VirtuGuard/BOS_AI/resolve/main/app.py
- Command line
-
hf download hf://spaces/VirtuGuard/BOS_AI/app.py
-
curl -L -o app.py https://huggingface.co/spaces/VirtuGuard/BOS_AI/resolve/main/app.py
37.4 kB
| """ | |
| BOS AI Training Platform | |
| A guided, module based course on using AI at BOS Security, delivered as a | |
| HuggingFace Gradio Space. | |
| Content: modules.py (lessons and takeaways), quizzes.py (knowledge checks), | |
| teams.py (tools by team), figures.py (SVG diagrams). Logo: logo.png. | |
| Pages: 0 = Overview, 1..N = modules, N+1 = Tools by team. | |
| Progress is kept per browser session (gr.State), not stored server side. | |
| """ | |
| import base64 | |
| import html | |
| import re | |
| from pathlib import Path | |
| import gradio as gr | |
| from modules import MODULES, IT_HELP | |
| from quizzes import QUIZZES | |
| from teams import TEAMS, TEAMS_INTRO | |
| from figures import FIGURES, TEAM_FIGURE | |
| from videos import VIDEOS, LINKS, TEAM_LINKS | |
| # Palette sampled from the BOS logo, teal as the single accent | |
| BLUE = "#15516B" | |
| BLUE_DEEP = "#0E3A4D" | |
| TEAL = "#13A89E" | |
| TEAL_DEEP = "#0E7F78" | |
| INK = "#1B2430" | |
| MUTED = "#61707E" | |
| PAPER = "#FFFFFF" | |
| WASH = "#F3F6F9" | |
| RULE = "#E1E8ED" | |
| MISS = "#C0563A" | |
| LOGO_PATH = Path(__file__).with_name("logo.png") | |
| LOGO_SRC = "" | |
| if LOGO_PATH.exists(): | |
| LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode() | |
| N = len(MODULES) | |
| HOME, TEAM_PAGE = 0, N + 1 | |
| MAXQ = max(len(q) for q in QUIZZES) | |
| TOTAL_Q = sum(len(q) for q in QUIZZES) | |
| def _minutes(m): | |
| words = sum(len(re.sub(r"<[^>]+>", " ", s["body"]).split()) for s in m["sections"]) | |
| return max(3, round(words / 200) + 1) | |
| MINUTES = [_minutes(m) for m in MODULES] | |
| CSS = f""" | |
| :root {{ --blue: {BLUE}; --blue-deep: {BLUE_DEEP}; --teal: {TEAL}; --teal-deep: {TEAL_DEEP}; --ink: {INK}; --muted: {MUTED}; | |
| --paper: {PAPER}; --wash: {WASH}; --rule: {RULE}; --miss: {MISS}; | |
| --shadow: 0 1px 2px rgba(14,58,77,.05), 0 8px 24px -12px rgba(14,58,77,.18); }} | |
| * {{ font-family: Arial, Helvetica, sans-serif !important; }} | |
| body, .gradio-container {{ background: var(--wash) !important; color: var(--ink); }} | |
| .gradio-container {{ max-width: 1320px !important; margin: 0 auto !important; padding: 18px 22px 64px !important; }} | |
| footer {{ display: none !important; }} | |
| .gradio-container .block, .gradio-container .form {{ box-shadow: none; }} | |
| @keyframes rise {{ from {{ opacity: 0; transform: translateY(8px); }} to {{ opacity: 1; transform: none; }} }} | |
| @media (prefers-reduced-motion: reduce) {{ *, *::before, *::after {{ animation: none !important; transition: none !important; }} }} | |
| /* Top bar */ | |
| .topbar {{ display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; | |
| background: var(--paper); border: 1px solid var(--rule); border-radius: 16px; padding: 14px 22px; box-shadow: var(--shadow); }} | |
| .topbar .brand {{ display: flex; align-items: center; gap: 18px; }} | |
| .topbar img {{ height: 46px; width: auto; display: block; }} | |
| .topbar .divider {{ width: 1px; height: 38px; background: var(--rule); }} | |
| .topbar h1 {{ margin: 0; font-size: 19px; font-weight: 700; color: var(--blue); letter-spacing: -0.01em; }} | |
| .topbar .tag {{ margin: 2px 0 0; font-size: 13px; color: var(--muted); }} | |
| .topbar .right {{ display: flex; align-items: center; gap: 12px; }} | |
| .topbar .tool {{ font-size: 13px; color: var(--muted); padding: 8px 14px; border: 1px solid var(--rule); border-radius: 999px; background: var(--wash); }} | |
| .topbar .tool b {{ color: var(--blue); }} | |
| .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; }} | |
| .track {{ height: 5px; background: var(--rule); border-radius: 999px; overflow: hidden; margin: 12px 6px 6px; }} | |
| .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); }} | |
| /* Rail */ | |
| .rail {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 16px !important; box-shadow: var(--shadow); | |
| padding: 12px 10px 14px !important; position: sticky; top: 16px; align-self: flex-start; gap: 3px !important; }} | |
| .rail .rail-head {{ padding: 6px 12px 10px; font-size: 12.5px; color: var(--muted); }} | |
| .rail .rail-sub {{ padding: 14px 12px 6px; font-size: 12.5px; color: var(--muted); border-top: 1px solid var(--rule); margin-top: 8px; }} | |
| .rail button {{ width: 100%; justify-content: flex-start !important; text-align: left !important; padding: 11px 12px !important; margin: 0 !important; | |
| border-radius: 10px !important; font-size: 14.5px !important; line-height: 1.35 !important; font-weight: 400 !important; white-space: normal !important; | |
| transition: background .15s ease, color .15s ease, transform .1s ease; }} | |
| .rail button.secondary {{ background: transparent !important; border: 1px solid transparent !important; color: var(--ink) !important; box-shadow: none !important; }} | |
| .rail button.secondary:hover {{ background: var(--wash) !important; }} | |
| .rail button.primary {{ background: #E7F6F4 !important; border: 1px solid #C6EBE7 !important; color: var(--blue) !important; font-weight: 700 !important; box-shadow: none !important; }} | |
| .rail button.stop {{ background: transparent !important; border: 1px solid transparent !important; color: var(--teal-deep) !important; box-shadow: none !important; }} | |
| .rail button.stop:hover {{ background: var(--wash) !important; }} | |
| .rail button:active {{ transform: scale(.985); }} | |
| .rail button:focus-visible {{ outline: 2px solid var(--teal) !important; outline-offset: 2px; }} | |
| /* Cards shared */ | |
| .card {{ background: var(--paper); border: 1px solid var(--rule); border-radius: 16px; box-shadow: var(--shadow); animation: rise .3s ease both; }} | |
| /* Overview */ | |
| .hero {{ display: grid; grid-template-columns: 1fr 220px; gap: 32px; align-items: center; padding: 44px 52px; | |
| background: linear-gradient(135deg, var(--blue-deep) 0%, var(--blue) 55%, #17667f 100%); color: #fff; border-color: transparent; }} | |
| .hero .eyebrow {{ font-size: 13px; color: #A9D8D3; font-weight: 700; margin: 0 0 10px; }} | |
| .hero h2 {{ font-size: 40px; line-height: 1.08; margin: 0 0 14px; letter-spacing: -0.02em; color: #fff; }} | |
| .hero p {{ font-size: 17px; line-height: 1.55; color: #D5E3EA; margin: 0 0 22px; max-width: 58ch; }} | |
| .hero .stats {{ display: flex; gap: 26px; flex-wrap: wrap; }} | |
| .hero .stat b {{ display: block; font-size: 22px; color: #fff; }} | |
| .hero .stat span {{ font-size: 13px; color: #A9C4D0; }} | |
| .hero .ring {{ display: flex; flex-direction: column; align-items: center; gap: 10px; }} | |
| .hero .ring svg {{ width: 160px; height: 160px; }} | |
| .hero .ring .label {{ font-size: 13px; color: #A9D8D3; }} | |
| .resume {{ display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px 24px; margin-top: 16px; border-left: 5px solid var(--teal); }} | |
| .resume .t {{ font-size: 13px; color: var(--muted); margin: 0 0 3px; }} | |
| .resume .n {{ font-size: 18px; font-weight: 700; color: var(--blue); margin: 0; }} | |
| .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; }} | |
| .resume button:hover {{ background: var(--teal-deep); }} | |
| .resume button:active {{ transform: scale(.98); }} | |
| .grid-head {{ display: flex; align-items: baseline; justify-content: space-between; margin: 30px 4px 14px; }} | |
| .grid-head h3 {{ font-size: 20px; color: var(--blue); margin: 0; }} | |
| .grid-head span {{ font-size: 13.5px; color: var(--muted); }} | |
| .mgrid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }} | |
| .mcard {{ position: relative; display: flex; flex-direction: column; gap: 10px; padding: 22px 22px 20px; background: var(--paper); border: 1px solid var(--rule); | |
| border-radius: 16px; box-shadow: var(--shadow); cursor: pointer; text-align: left; font: inherit; color: var(--ink); | |
| transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; animation: rise .3s ease both; }} | |
| .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); }} | |
| .mcard:focus-visible {{ outline: 2px solid var(--teal); outline-offset: 2px; }} | |
| .mcard .row {{ display: flex; align-items: center; justify-content: space-between; }} | |
| .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; }} | |
| .mcard.done .num {{ background: var(--teal); }} | |
| .mcard .status {{ font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; background: var(--wash); color: var(--muted); }} | |
| .mcard.done .status {{ background: #E7F6F4; color: var(--teal-deep); }} | |
| .mcard.required .status.req {{ background: var(--blue); color: #fff; }} | |
| .mcard h4 {{ margin: 6px 0 0; font-size: 18px; line-height: 1.25; color: var(--blue); letter-spacing: -0.01em; }} | |
| .mcard p {{ margin: 0; font-size: 14.5px; line-height: 1.5; color: var(--muted); flex: 1; }} | |
| .mcard .foot {{ display: flex; justify-content: space-between; font-size: 12.5px; color: var(--muted); padding-top: 12px; border-top: 1px solid var(--rule); }} | |
| .mcard .foot b {{ color: var(--teal-deep); font-weight: 700; }} | |
| .mcard.ref {{ background: linear-gradient(180deg, #F7FBFB, #EEF7F6); }} | |
| /* Reader */ | |
| .reader {{ padding: 46px 64px 38px; }} | |
| .reader .meta {{ display: flex; align-items: center; gap: 12px; margin-bottom: 16px; font-size: 13px; color: var(--muted); }} | |
| .pill {{ display: inline-block; padding: 4px 11px; border-radius: 999px; font-weight: 700; font-size: 12px; background: #E7F6F4; color: var(--teal-deep); }} | |
| .pill.required {{ background: var(--blue); color: #fff; }} | |
| .pill.done {{ background: var(--teal); color: #fff; }} | |
| .reader h2 {{ font-size: 36px; line-height: 1.1; color: var(--blue); margin: 0 0 12px; letter-spacing: -0.02em; }} | |
| .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; }} | |
| .reader h3 {{ font-size: 21px; color: var(--blue); margin: 38px 0 12px; letter-spacing: -0.01em; }} | |
| .reader h3:first-of-type {{ margin-top: 0; }} | |
| .reader p {{ font-size: 17px; line-height: 1.68; margin: 0 0 15px; max-width: 68ch; color: var(--ink); }} | |
| .reader p b {{ color: var(--blue); }} | |
| .fig {{ margin: 24px 0 30px; padding: 24px 26px 16px; background: var(--wash); border: 1px solid var(--rule); border-radius: 14px; max-width: 760px; }} | |
| .fig svg {{ width: 100%; height: auto; display: block; }} | |
| .fig figcaption {{ margin-top: 12px; font-size: 13.5px; color: var(--muted); line-height: 1.45; }} | |
| .takeaways {{ margin-top: 44px; padding: 26px 30px; border-radius: 14px; background: linear-gradient(135deg, #EEF7F6, #F7FBFB); border: 1px solid #CFEAE7; max-width: 760px; }} | |
| .takeaways h3 {{ margin: 0 0 14px !important; font-size: 17px !important; color: var(--teal-deep) !important; }} | |
| .takeaways ol {{ margin: 0; padding-left: 0; list-style: none; counter-reset: t; }} | |
| .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); }} | |
| .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; }} | |
| .help {{ margin-top: 22px; padding: 18px 22px; background: var(--wash); border-radius: 12px; border-left: 4px solid var(--blue); max-width: 760px; }} | |
| .help p {{ margin: 0; max-width: none; font-size: 15px; }} | |
| .help p b {{ color: var(--blue); }} | |
| /* Watch panel */ | |
| .watch {{ margin: 0 0 36px; padding: 22px 24px 20px; background: linear-gradient(180deg, #F7FBFB, var(--wash)); border: 1px solid var(--rule); border-radius: 14px; max-width: 760px; }} | |
| .watch .wh {{ display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }} | |
| .watch .wh h3 {{ margin: 0 !important; font-size: 17px !important; color: var(--teal-deep) !important; }} | |
| .watch .wh span {{ font-size: 13px; color: var(--muted); }} | |
| .watch .frame {{ position: relative; width: 100%; padding-top: 56.25%; border-radius: 12px; overflow: hidden; background: #0E3A4D; box-shadow: 0 10px 28px -14px rgba(14,58,77,.45); }} | |
| .watch .frame iframe {{ position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }} | |
| .watch .wt {{ margin: 14px 0 4px; font-size: 16px; font-weight: 700; color: var(--blue); }} | |
| .watch .wb {{ margin: 0; font-size: 14.5px; line-height: 1.55; color: var(--ink); max-width: none; }} | |
| .watch .ws {{ margin: 8px 0 0; font-size: 12.5px; color: var(--muted); max-width: none; }} | |
| .watch .ws a {{ color: var(--teal-deep); text-decoration: none; font-weight: 700; }} | |
| .watch + .watch {{ margin-top: -18px; }} | |
| /* Further learning cards */ | |
| .more {{ margin-top: 26px; max-width: 760px; }} | |
| .more h3 {{ font-size: 17px !important; color: var(--blue) !important; margin: 0 0 12px !important; }} | |
| .more .lgrid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }} | |
| .more a.lcard {{ display: block; padding: 16px 18px; border: 1px solid var(--rule); border-radius: 12px; background: var(--paper); text-decoration: none; color: var(--ink); | |
| transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease; }} | |
| .more a.lcard:hover {{ border-color: #BFE3DF; transform: translateY(-2px); box-shadow: var(--shadow); }} | |
| .more a.lcard .lt {{ font-size: 15px; font-weight: 700; color: var(--blue); margin: 0 0 3px; max-width: none; }} | |
| .more a.lcard .lm {{ font-size: 12.5px; color: var(--teal-deep); font-weight: 700; margin: 0 0 8px; max-width: none; }} | |
| .more a.lcard .lb {{ font-size: 14px; line-height: 1.5; color: var(--muted); margin: 0; max-width: none; }} | |
| @media (max-width: 720px) {{ .more .lgrid {{ grid-template-columns: 1fr; }} }} | |
| /* Quiz */ | |
| .quiz {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; border-radius: 16px !important; box-shadow: var(--shadow); | |
| padding: 30px 40px 28px !important; margin-top: 16px; gap: 4px !important; }} | |
| .quiz .qhead {{ display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 8px; }} | |
| .quiz .qhead h3 {{ font-size: 21px; color: var(--blue); margin: 0; }} | |
| .quiz .qhead span {{ font-size: 13px; color: var(--muted); }} | |
| .quiz .qsub {{ color: var(--muted); font-size: 15px; margin: 0 0 8px; max-width: 70ch; }} | |
| .quiz, .quiz .block, .quiz .form, .quiz fieldset, .quiz .wrap {{ background: var(--paper) !important; color: var(--ink) !important; }} | |
| .quiz .block, .quiz .form {{ border: none !important; box-shadow: none !important; padding: 12px 0 6px !important; margin: 0 !important; }} | |
| .quiz fieldset {{ border: none !important; padding: 0 !important; margin: 0 !important; }} | |
| .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; | |
| background: transparent !important; margin-bottom: 10px !important; display: block; line-height: 1.4; }} | |
| .quiz .wrap {{ display: flex; flex-direction: column; gap: 7px !important; }} | |
| .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; | |
| background: var(--paper) !important; cursor: pointer; font-size: 15px; box-shadow: none !important; transition: border-color .12s ease, background .12s ease, transform .08s ease; }} | |
| .quiz .wrap label:hover {{ border-color: var(--teal) !important; background: #FAFDFD !important; }} | |
| .quiz .wrap label:active {{ transform: scale(.995); }} | |
| .quiz .wrap label.selected {{ border-color: var(--teal) !important; background: #E7F6F4 !important; }} | |
| .quiz .wrap label, .quiz .wrap label span, .quiz .wrap label * {{ color: var(--ink) !important; }} | |
| .quiz .wrap label.selected span {{ color: var(--blue) !important; font-weight: 700; }} | |
| .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; }} | |
| .quiz .check {{ background: var(--blue) !important; border: 1px solid var(--blue) !important; color: #fff !important; box-shadow: none !important; border-radius: 10px !important; | |
| font-weight: 700 !important; padding: 13px 24px !important; margin-top: 10px; width: auto; transition: background .15s ease, transform .1s ease; }} | |
| .quiz .check:hover {{ background: var(--blue-deep) !important; }} | |
| .quiz .check:active {{ transform: scale(.98); }} | |
| .result {{ margin-top: 14px; animation: rise .25s ease both; }} | |
| .result .scorecard {{ display: flex; align-items: center; gap: 18px; padding: 16px 20px; border-radius: 12px; background: var(--wash); margin-bottom: 14px; }} | |
| .result .scorecard svg {{ width: 64px; height: 64px; flex: none; }} | |
| .result .scorecard .s1 {{ font-size: 18px; font-weight: 700; color: var(--blue); margin: 0 0 2px; }} | |
| .result .scorecard .s2 {{ font-size: 14px; color: var(--muted); margin: 0; }} | |
| .result .item {{ padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; font-size: 15px; line-height: 1.5; }} | |
| .result .item.ok {{ background: #E7F6F4; border-left: 4px solid var(--teal); }} | |
| .result .item.miss {{ background: #FBEFEA; border-left: 4px solid var(--miss); }} | |
| .result .item p {{ margin: 0 0 6px; }} | |
| .result .item p:last-child {{ margin: 0; }} | |
| .result .item b {{ color: var(--blue); }} | |
| .result .warn {{ color: var(--miss); font-weight: 700; }} | |
| /* Tools by team */ | |
| .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; }} | |
| .teamwrap .tabs button {{ font-size: 15px !important; font-weight: 700 !important; color: var(--muted) !important; border: none !important; border-bottom: 3px solid transparent !important; | |
| border-radius: 0 !important; padding: 14px 18px !important; background: transparent !important; box-shadow: none !important; }} | |
| .teamwrap .tabs button.selected {{ color: var(--blue) !important; border-bottom-color: var(--teal) !important; }} | |
| .teamwrap .tabitem {{ border: none !important; padding: 4px 0 0 !important; background: transparent !important; }} | |
| .teamwrap .tabitem h3 {{ font-size: 19px; color: var(--blue); margin: 26px 0 8px; }} | |
| .teamwrap .tabitem h3:first-child {{ margin-top: 12px; }} | |
| .teamwrap .tabitem p {{ font-size: 16.5px; line-height: 1.65; margin: 0 0 12px; max-width: 72ch; color: var(--ink); }} | |
| .teamwrap .tabitem p b {{ color: var(--blue); }} | |
| /* Nav */ | |
| .navrow {{ margin-top: 14px; align-items: center; }} | |
| .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; }} | |
| .navrow button:active {{ transform: scale(.98); }} | |
| .navrow .primary {{ background: var(--teal) !important; border: 1px solid var(--teal) !important; color: #fff !important; box-shadow: none !important; }} | |
| .navrow .primary:hover {{ background: var(--teal-deep) !important; }} | |
| .navrow .secondary {{ background: var(--paper) !important; border: 1px solid var(--rule) !important; color: var(--blue) !important; box-shadow: none !important; }} | |
| .navrow .secondary:hover {{ background: var(--wash) !important; }} | |
| .progress {{ text-align: center; color: var(--muted); font-size: 14px; }} | |
| @media (max-width: 1000px) {{ .mgrid {{ grid-template-columns: repeat(2, 1fr); }} .hero {{ grid-template-columns: 1fr; padding: 32px; }} .hero .ring {{ display: none; }} }} | |
| @media (max-width: 720px) {{ | |
| .rail {{ position: static; }} .mgrid {{ grid-template-columns: 1fr; }} | |
| .reader {{ padding: 28px 22px; }} .quiz {{ padding: 22px 20px !important; }} .teamwrap .tabs {{ padding: 8px 18px 24px !important; }} | |
| .reader h2 {{ font-size: 28px; }} .hero h2 {{ font-size: 30px; }} .topbar .divider {{ display: none; }} | |
| }} | |
| """ | |
| FORCE_LIGHT = """ | |
| () => { | |
| const strip = () => { | |
| document.body.classList.remove('dark'); | |
| document.documentElement.classList.remove('dark'); | |
| document.querySelectorAll('.dark').forEach(el => el.classList.remove('dark')); | |
| }; | |
| strip(); | |
| new MutationObserver(strip).observe(document.body, { attributes: true, attributeFilter: ['class'] }); | |
| } | |
| """ | |
| THEME = gr.themes.Default(primary_hue="teal", neutral_hue="slate").set( | |
| body_background_fill=WASH, body_background_fill_dark=WASH, | |
| block_background_fill=PAPER, block_background_fill_dark=PAPER, | |
| block_label_text_color=INK, block_label_text_color_dark=INK, | |
| body_text_color=INK, body_text_color_dark=INK, | |
| border_color_primary=RULE, border_color_primary_dark=RULE, | |
| checkbox_label_background_fill=PAPER, checkbox_label_background_fill_dark=PAPER, | |
| checkbox_label_background_fill_selected="#E7F6F4", checkbox_label_background_fill_selected_dark="#E7F6F4", | |
| checkbox_label_text_color=INK, checkbox_label_text_color_dark=INK, | |
| checkbox_label_text_color_selected=BLUE, checkbox_label_text_color_selected_dark=BLUE, | |
| checkbox_background_color=PAPER, checkbox_background_color_dark=PAPER, | |
| checkbox_background_color_selected=TEAL, checkbox_background_color_selected_dark=TEAL, | |
| checkbox_border_color=MUTED, checkbox_border_color_dark=MUTED, | |
| ) | |
| # Rendering helpers | |
| def ring(pct: int, size=160, stroke=12, track="rgba(255,255,255,.18)", color=TEAL, text_color="#fff", sub=""): | |
| r = (size - stroke) / 2 | |
| c = 2 * 3.14159 * r | |
| dash = c * pct / 100 | |
| 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 "" | |
| return ( | |
| f'<svg viewBox="0 0 {size} {size}" role="img" aria-label="{pct} percent complete">' | |
| f'<circle cx="{size/2}" cy="{size/2}" r="{r}" fill="none" stroke="{track}" stroke-width="{stroke}"/>' | |
| f'<circle cx="{size/2}" cy="{size/2}" r="{r}" fill="none" stroke="{color}" stroke-width="{stroke}" stroke-linecap="round" ' | |
| f'stroke-dasharray="{dash:.1f} {c:.1f}" transform="rotate(-90 {size/2} {size/2})" style="transition:stroke-dasharray .6s ease"/>' | |
| 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>' | |
| f"{sub_el}</svg>" | |
| ) | |
| def pct_done(progress: dict) -> int: | |
| return round(100 * len(progress) / N) | |
| def figure(svg_markup: str, caption: str) -> str: | |
| return f'<figure class="fig">{svg_markup}<figcaption>{html.escape(caption)}</figcaption></figure>' | |
| def render_home(progress: dict) -> str: | |
| done = len(progress) | |
| pct = pct_done(progress) | |
| next_idx = next((i for i in range(N) if i not in progress), None) | |
| total_min = sum(MINUTES) | |
| parts = [f""" | |
| <div class="card hero"> | |
| <div> | |
| <p class="eyebrow">BOS Security company training</p> | |
| <h2>Using AI well, and safely, at BOS</h2> | |
| <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> | |
| <div class="stats"> | |
| <div class="stat"><b>{N}</b><span>modules</span></div> | |
| <div class="stat"><b>{total_min} min</b><span>total reading</span></div> | |
| <div class="stat"><b>{TOTAL_Q}</b><span>check questions</span></div> | |
| <div class="stat"><b>{done} of {N}</b><span>completed</span></div> | |
| </div> | |
| </div> | |
| <div class="ring">{ring(pct, sub="complete")}<span class="label">Progress this session</span></div> | |
| </div>"""] | |
| if next_idx is not None: | |
| m = MODULES[next_idx] | |
| label = "Start here" if done == 0 else "Continue where you left off" | |
| parts.append(f""" | |
| <div class="card resume"> | |
| <div><p class="t">{label}</p><p class="n">Module {next_idx + 1}. {html.escape(m['title'])}</p></div> | |
| <button type="button" onclick="document.getElementById('rail-{next_idx + 1}').click()">{'Begin module' if done == 0 else 'Resume'}</button> | |
| </div>""") | |
| else: | |
| parts.append(""" | |
| <div class="card resume"> | |
| <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> | |
| <button type="button" onclick="document.getElementById('rail-team').click()">Open Tools by team</button> | |
| </div>""") | |
| 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">') | |
| for i, m in enumerate(MODULES): | |
| is_done = i in progress | |
| req = m["depth"].lower() == "required" | |
| cls = "mcard" + (" done" if is_done else "") + (" required" if req else "") | |
| if is_done: | |
| status = f'<span class="status">Scored {progress[i][0]} of {progress[i][1]}</span>' | |
| elif req: | |
| status = '<span class="status req">Required</span>' | |
| else: | |
| status = f'<span class="status">{html.escape(m["depth"])}</span>' | |
| parts.append(f""" | |
| <button type="button" class="{cls}" style="animation-delay:{i*40}ms" onclick="document.getElementById('rail-{i + 1}').click()"> | |
| <div class="row"><span class="num">{'✓' if is_done else i + 1}</span>{status}</div> | |
| <h4>{html.escape(m['title'])}</h4> | |
| <p>{html.escape(m['summary'])}</p> | |
| <div class="foot"><span>{MINUTES[i]} min read{(' + ' + str(sum(v['minutes'] for v in VIDEOS.get(i, []))) + ' min video') if VIDEOS.get(i) else ''}</span><b>{len(QUIZZES[i])} questions</b></div> | |
| </button>""") | |
| parts.append(f""" | |
| <button type="button" class="mcard ref" style="animation-delay:{N*40}ms" onclick="document.getElementById('rail-team').click()"> | |
| <div class="row"><span class="num" style="background:var(--teal-deep)">☰</span><span class="status">Reference</span></div> | |
| <h4>Tools by team</h4> | |
| <p>Where Claude fits next to Zoho, Apollo, and Microsoft 365 for Staffing, Security, Sales, and VirtuGuard.</p> | |
| <div class="foot"><span>4 teams</span><b>Open any time</b></div> | |
| </button></div>""") | |
| return "\n".join(parts) | |
| def render_module(idx: int, progress: dict) -> str: | |
| m = MODULES[idx] | |
| req = m["depth"].lower() == "required" | |
| pills = f'<span class="pill{" required" if req else ""}">{html.escape(m["depth"])}</span>' | |
| if idx in progress: | |
| pills += f'<span class="pill done">Scored {progress[idx][0]} of {progress[idx][1]}</span>' | |
| parts = [ | |
| '<div class="card reader">', | |
| f'<div class="meta">{pills}<span>Module {idx + 1} of {N}</span><span>{MINUTES[idx]} min read</span></div>', | |
| f'<h2>{html.escape(m["title"])}</h2>', | |
| f'<p class="summary">{html.escape(m["summary"])}</p>', | |
| ] | |
| for v in VIDEOS.get(idx, []): | |
| parts.append(watch_panel(v)) | |
| for s in m["sections"]: | |
| parts.append(f'<h3>{html.escape(s["heading"])}</h3>') | |
| parts.append(s["body"]) | |
| fig = FIGURES.get((idx, s["heading"])) | |
| if fig: | |
| parts.append(figure(*fig)) | |
| parts.append('<div class="takeaways"><h3>Key takeaways</h3><ol>' + "".join(f"<li>{html.escape(t)}</li>" for t in m["takeaways"]) + "</ol></div>") | |
| parts.append(link_cards(LINKS.get(idx, []))) | |
| parts.append(f'<div class="help"><p><b>Need help?</b> {html.escape(IT_HELP)}</p></div>') | |
| parts.append("</div>") | |
| return "\n".join(parts) | |
| def watch_panel(v: dict) -> str: | |
| src = f"https://www.youtube-nocookie.com/embed/{v['id']}?rel=0&modestbranding=1" | |
| return ( | |
| '<div class="watch">' | |
| f'<div class="wh"><h3>Watch</h3><span>Anthropic, {v["minutes"]} min</span></div>' | |
| f'<div class="frame"><iframe src="{src}" title="{html.escape(v["title"])}" loading="lazy" ' | |
| 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ' | |
| 'referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe></div>' | |
| f'<p class="wt">{html.escape(v["title"])}</p><p class="wb">{html.escape(v["blurb"])}</p>' | |
| f'<p class="ws">Published by Anthropic. <a href="{v["source"]}" target="_blank" rel="noopener">Open the original tutorial page</a></p>' | |
| "</div>" | |
| ) | |
| def link_cards(links: list, heading: str = "Go further with Anthropic's free courses") -> str: | |
| if not links: | |
| return "" | |
| cards = "".join( | |
| f'<a class="lcard" href="{l["url"]}" target="_blank" rel="noopener"><p class="lt">{html.escape(l["title"])}</p>' | |
| f'<p class="lm">{html.escape(l["meta"])}</p><p class="lb">{html.escape(l["blurb"])}</p></a>' | |
| for l in links | |
| ) | |
| return f'<div class="more"><h3>{html.escape(heading)}</h3><div class="lgrid">{cards}</div></div>' | |
| def render_team_header() -> str: | |
| return ( | |
| '<div class="card reader" style="padding-bottom:22px">' | |
| '<div class="meta"><span class="pill">Reference</span><span>Tools by team</span></div>' | |
| "<h2>Where Claude fits next to Zoho, Apollo, and Microsoft 365</h2>" | |
| f"{TEAMS_INTRO}{figure(*TEAM_FIGURE)}{link_cards(TEAM_LINKS, 'Role based tutorials from Anthropic')}</div>" | |
| ) | |
| def track(idx: int, progress: dict) -> str: | |
| return f'<div class="track"><div style="width:{pct_done(progress)}%"></div></div>' | |
| def pct_badge(progress: dict) -> str: | |
| return f'<div class="pct">{pct_done(progress)}% complete</div>' | |
| def progress_text(page: int) -> str: | |
| if page == HOME: | |
| return '<div class="progress">Overview</div>' | |
| if page == TEAM_PAGE: | |
| return '<div class="progress">Reference</div>' | |
| return f'<div class="progress">Module {page} of {N}</div>' | |
| def rail_labels(progress: dict): | |
| labels = ["Overview"] | |
| for i, m in enumerate(MODULES): | |
| mark = "\u2713 " if i in progress else "" | |
| labels.append(f"{mark}{i + 1}. {m['title']}") | |
| labels.append("Tools by team") | |
| return labels | |
| def rail_updates(page: int, progress: dict): | |
| labels = rail_labels(progress) | |
| out = [] | |
| for i, lab in enumerate(labels): | |
| if i == page: | |
| variant = "primary" | |
| elif 1 <= i <= N and (i - 1) in progress: | |
| variant = "stop" | |
| else: | |
| variant = "secondary" | |
| out.append(gr.update(value=lab, variant=variant)) | |
| return out | |
| def quiz_updates(page: int): | |
| qs = QUIZZES[page - 1] if 1 <= page <= N else [] | |
| out = [] | |
| for i in range(MAXQ): | |
| if i < len(qs): | |
| out.append(gr.update(label=f"{i + 1}. {qs[i]['q']}", choices=qs[i]["options"], value=None, visible=True)) | |
| else: | |
| out.append(gr.update(visible=False, value=None)) | |
| return out | |
| def quiz_head(page: int) -> str: | |
| n = len(QUIZZES[page - 1]) if 1 <= page <= N else 0 | |
| return (f'<div class="qhead"><h3>Knowledge check</h3><span>{n} questions</span></div>' | |
| '<p class="qsub">Pick one answer per question, then check. Anything you miss shows the correct answer and why. ' | |
| 'Checking your answers marks the module complete for this session; you can retake it any time.</p>') | |
| def show(page: int, progress: dict): | |
| page = max(HOME, min(TEAM_PAGE, page)) | |
| on_home, on_team = page == HOME, page == TEAM_PAGE | |
| on_module = not on_home and not on_team | |
| next_label = "Start module 1" if on_home else ("Tools by team" if page == N else "Next module") | |
| return [ | |
| page, | |
| gr.update(visible=on_home), | |
| gr.update(visible=on_module), | |
| gr.update(visible=on_team), | |
| render_home(progress) if on_home else "", | |
| render_module(page - 1, progress) if on_module else "", | |
| track(page, progress), | |
| pct_badge(progress), | |
| progress_text(page), | |
| gr.update(interactive=page > HOME, value="Overview" if page == 1 else "Previous module"), | |
| gr.update(interactive=page < TEAM_PAGE, value=next_label), | |
| quiz_head(page), | |
| "", | |
| *rail_updates(page, progress), | |
| *quiz_updates(page), | |
| ] | |
| def grade(page: int, progress: dict, *answers): | |
| idx = page - 1 | |
| qs = QUIZZES[idx] | |
| unanswered = [i + 1 for i, a in enumerate(answers[: len(qs)]) if a is None] | |
| if unanswered: | |
| nums = ", ".join(str(n) for n in unanswered) | |
| word = "question" if len(unanswered) == 1 else "questions" | |
| return [progress, f'<div class="result"><p class="warn">Answer {word} {nums} before checking.</p></div>', | |
| gr.update(), gr.update(), gr.update(), *[gr.update() for _ in range(N + 2)]] | |
| items, correct = [], 0 | |
| for i, q in enumerate(qs): | |
| chosen, right = answers[i], q["options"][q["answer"]] | |
| if chosen == right: | |
| correct += 1 | |
| items.append(f'<div class="item ok"><p><b>Question {i + 1}.</b> Correct.</p><p>{html.escape(right)}</p></div>') | |
| else: | |
| items.append(f'<div class="item miss"><p><b>Question {i + 1}.</b> Not quite. You chose: {html.escape(chosen)}</p>' | |
| f'<p><b>Correct answer:</b> {html.escape(right)}</p><p>{html.escape(q["why"])}</p></div>') | |
| pct = round(100 * correct / len(qs)) | |
| progress = dict(progress) | |
| progress[idx] = (correct, len(qs)) | |
| if correct == len(qs): | |
| s1, s2 = f"All {len(qs)} correct.", "Module marked complete. Move on when you are ready." | |
| else: | |
| s1, s2 = f"{correct} of {len(qs)} right.", "Read the explanations below, then move on. You can retake the check any time." | |
| card = (f'<div class="scorecard">{ring(pct, size=64, stroke=7, track="#E1E8ED", color=TEAL if pct == 100 else BLUE, text_color=BLUE)}' | |
| f'<div><p class="s1">{s1}</p><p class="s2">{s2}</p></div></div>') | |
| result = f'<div class="result">{card}{"".join(items)}</div>' | |
| return [progress, result, track(page, progress), pct_badge(progress), | |
| render_module(idx, progress), *rail_updates(page, progress)] | |
| with gr.Blocks(css=CSS, js=FORCE_LIGHT, theme=THEME, title="BOS AI Training") as demo: | |
| logo_html = f'<img src="{LOGO_SRC}" alt="BOS">' if LOGO_SRC else "" | |
| with gr.Row(elem_classes=["topbar-row"]): | |
| topbar = gr.HTML(f""" | |
| <div class="topbar"> | |
| <div class="brand">{logo_html}<div class="divider"></div> | |
| <div><h1>AI Training Platform</h1><p class="tag">Six modules with knowledge checks, plus a tools by team reference.</p></div> | |
| </div> | |
| <div class="right"><div class="tool">Company standard: <b>Claude Enterprise</b></div></div> | |
| </div>""") | |
| bar = gr.HTML(track(HOME, {})) | |
| page_state = gr.State(HOME) | |
| progress_state = gr.State({}) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=3, min_width=250, elem_classes=["rail"]): | |
| gr.HTML('<div class="rail-head">Learning path</div>') | |
| rail_btns = [gr.Button("Overview", variant="primary", elem_id="rail-0")] | |
| for i, m in enumerate(MODULES): | |
| rail_btns.append(gr.Button(f"{i + 1}. {m['title']}", variant="secondary", elem_id=f"rail-{i + 1}")) | |
| gr.HTML('<div class="rail-sub">Reference</div>') | |
| rail_btns.append(gr.Button("Tools by team", variant="secondary", elem_id="rail-team")) | |
| pct_html = gr.HTML(pct_badge({})) | |
| with gr.Column(scale=8): | |
| with gr.Column(visible=True) as home_view: | |
| home = gr.HTML(render_home({})) | |
| with gr.Column(visible=False) as module_view: | |
| content = gr.HTML("") | |
| with gr.Column(elem_classes=["quiz"]): | |
| qhead = gr.HTML(quiz_head(1)) | |
| radios = [gr.Radio(choices=[], label="", visible=False) for _ in range(MAXQ)] | |
| check_btn = gr.Button("Check answers", elem_classes=["check"]) | |
| result = gr.HTML("") | |
| with gr.Column(visible=False, elem_classes=["teamwrap"]) as team_view: | |
| gr.HTML(render_team_header()) | |
| with gr.Tabs(): | |
| for t in TEAMS: | |
| with gr.Tab(t["name"]): | |
| gr.HTML(t["html"]) | |
| with gr.Row(elem_classes=["navrow"]): | |
| prev_btn = gr.Button("Previous module", variant="secondary", interactive=False) | |
| progress_lbl = gr.HTML(progress_text(HOME)) | |
| next_btn = gr.Button("Start module 1", variant="primary") | |
| outputs = [page_state, home_view, module_view, team_view, home, content, bar, pct_html, progress_lbl, | |
| prev_btn, next_btn, qhead, result, *rail_btns, *radios] | |
| for i, b in enumerate(rail_btns): | |
| b.click(lambda prog, i=i: show(i, prog), inputs=progress_state, outputs=outputs) | |
| prev_btn.click(lambda p, prog: show(p - 1, prog), inputs=[page_state, progress_state], outputs=outputs) | |
| next_btn.click(lambda p, prog: show(p + 1, prog), inputs=[page_state, progress_state], outputs=outputs) | |
| check_btn.click(grade, inputs=[page_state, progress_state, *radios], | |
| outputs=[progress_state, result, bar, pct_html, content, *rail_btns]) | |
| demo.load(lambda: show(HOME, {}), outputs=outputs) | |
| if __name__ == "__main__": | |
| demo.launch() | |