Spaces:
Sleeping
Sleeping
Download app/main.py from SkillArbitrage/UpworkAutomation: direct link, hf CLI and curl.
- Browser
- Download file 25.6 kB
-
https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/main.py
- Command line
-
hf download hf://spaces/SkillArbitrage/UpworkAutomation/app/main.py
-
curl -L -o main.py https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/main.py
25.6 kB
| """Upwork Proposal Strategist β Streamlit entry point. | |
| End users never run this directly β they double-click the launcher (see | |
| README), which opens the app in their web browser. Developers launch it via | |
| ``python desktop_app.py`` (same thing β starts the server and opens the browser) | |
| or, after ``pip install -e .``, ``python -m streamlit run app/main.py`` β no | |
| PYTHONPATH needed in either case, because the project is an installable package | |
| and the launcher puts the project root on ``sys.path``. See DEVELOPER.md. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import traceback | |
| from datetime import datetime, timezone | |
| import streamlit as st | |
| from app.config import ( | |
| APP_TITLE, APP_TAGLINE, | |
| get_settings, reload_settings, | |
| get_saved_dossier_path, save_dossier_path, | |
| apply_session_overrides, REMEMBERED_CONFIG_KEYS, | |
| ) | |
| from app.services import browser_tag, diagnostics, key_store | |
| from app.services.api_gate import ApiGateError, run_capability_test | |
| from app.services.folder_validator import score_evidence, validate | |
| from app.services.dossier_reader import read_dossier | |
| from app.services.evidence_index import build_evidence_index | |
| from app.ui import ( | |
| setup_screen, | |
| dossier_screen, | |
| interview_screen, | |
| screenshot_screen, | |
| confirmation_screen, | |
| analysis_screen, | |
| proposal_screen, | |
| ) | |
| from app.ui import api_usage_panel, diagnostics_panel, motion_runtime, theme | |
| from app.utils.logging_utils import get_logger | |
| _logger = get_logger("upwork_strategist.main") | |
| # (step key, sidebar label) in flow order. | |
| STEPS: list[tuple[str, str]] = [ | |
| ("setup", "Setup"), | |
| ("dossier", "Dossier"), | |
| ("screenshot", "Job Screenshot"), | |
| ("confirmation", "Confirm Details"), | |
| ("analysis", "Analysis"), | |
| ("proposal", "Proposal"), | |
| ] | |
| # "Confirm Details" is a developer/admin-only screen. Normal users get a | |
| # clean Setup β Dossier β Job Screenshot β Analysis β Proposal flow; the | |
| # extracted job fields are confirmed automatically in the backend right | |
| # after extraction, so these steps are hidden unless SHOW_DEBUG_PANEL=true. | |
| DEBUG_ONLY_STEPS: frozenset[str] = frozenset({"confirmation"}) | |
| # Screens that are a second route through a step rather than a step of their | |
| # own. They are reachable and renderable, but they are NOT in STEPS: they | |
| # borrow their parent's rail row, breadcrumb, step number and accent, so the | |
| # workflow stays the same length however the user builds their profile. | |
| # | |
| # The interview is one of these. It is an alternative to reading a dossier | |
| # folder β same step, same outcome (an evidence index), different input β so | |
| # giving it its own rail row would tell the user the flow grew a step when it | |
| # did not, and would leave "Step N of M" counting differently depending on | |
| # which route they took. | |
| STEP_PARENT: dict[str, str] = {"interview": "dossier"} | |
| def _parent_step(step_key: str) -> str: | |
| """The step a sub-screen reports as, for the rail and the top bar.""" | |
| return STEP_PARENT.get(step_key, step_key) | |
| def _visible_steps(show_debug: bool) -> list[tuple[str, str]]: | |
| """Return the steps shown in the sidebar for the current mode.""" | |
| if show_debug: | |
| return list(STEPS) | |
| return [(key, label) for key, label in STEPS if key not in DEBUG_ONLY_STEPS] | |
| RENDERERS = { | |
| "setup": setup_screen.render, | |
| "dossier": dossier_screen.render, | |
| "interview": interview_screen.render, | |
| "screenshot": screenshot_screen.render, | |
| "confirmation": confirmation_screen.render, | |
| "analysis": analysis_screen.render, | |
| "proposal": proposal_screen.render, | |
| } | |
| SESSION_DEFAULTS: dict[str, object] = { | |
| # Setup gate | |
| "api_status": None, | |
| "api_ok": False, | |
| # Theme (light default) | |
| "theme_dark": True, | |
| # Navigation | |
| "current_step": "setup", | |
| # Dossier flow | |
| "dossier_folder_path": "", | |
| "dossier_validation": None, | |
| "dossier_chunks": None, | |
| "dossier_read": False, | |
| "dossier_read_error": False, | |
| "evidence_index": None, | |
| "canonical_profile": None, | |
| "evidence_index_meta": None, | |
| # Interview (the second route through Dossier) | |
| "profile_source": "dossier", | |
| "interview_state": None, | |
| "interview_started": False, | |
| "interview_last_turn": None, | |
| "interview_turn_seq": 0, | |
| # Published by the interview when it builds a profile; None until then. | |
| # Declared here rather than left to spring into existence on the first | |
| # build, so every reader (Analysis scores with it, Dossier decides | |
| # whether to still offer a gap-fill interview with it) sees the same key | |
| # from the first rerun and a reset can return it to a value that exists. | |
| "interview_strength": None, | |
| # Screenshots | |
| "screenshots_uploaded": False, | |
| "uploaded_screenshots": [], | |
| "pasted_screenshots": [], | |
| "screenshot_upload_sig": (), | |
| "extracted_job_fields": None, | |
| # Confirmation | |
| "confirmed_job_fields": None, | |
| "fields_confirmed": False, | |
| "current_job_fingerprint": None, | |
| # Analysis output | |
| "scoring_result": None, | |
| "recommendation_result": None, | |
| "match_data": None, | |
| "generated_proposal": None, | |
| "verified_proposal": None, | |
| "proposal_context": None, | |
| "selected_evidence_for_proposal": None, | |
| # API usage tracking | |
| "api_usage_log": [], | |
| } | |
| def _init_session_state() -> None: | |
| for key, value in SESSION_DEFAULTS.items(): | |
| if key not in st.session_state: | |
| # A copy per session: this process serves every visitor, and the | |
| # defaults themselves would hand them all one shared list (the | |
| # usage log is appended to in place, so one visitor's calls and | |
| # failures would show up in everyone's panels and reports). | |
| st.session_state[key] = copy.deepcopy(value) | |
| def _is_unlocked(step_key: str) -> bool: | |
| if step_key == "setup": | |
| return True | |
| if step_key == "dossier": | |
| return bool(st.session_state.get("api_ok")) | |
| if step_key == "interview": | |
| # Same gate as its parent step: the interview needs a working API key | |
| # and nothing else. Without this branch the sweep below would bounce | |
| # the user straight back to Setup the moment they opened it. | |
| return bool(st.session_state.get("api_ok")) | |
| if step_key == "screenshot": | |
| return bool(st.session_state.get("evidence_index")) | |
| if step_key == "confirmation": | |
| return bool(st.session_state.get("extracted_job_fields")) | |
| if step_key == "analysis": | |
| return bool(st.session_state.get("fields_confirmed")) | |
| if step_key == "proposal": | |
| # Also locked while the analysis is being worked out again: a draft | |
| # started now would build on the verdict that is about to change. | |
| return bool( | |
| st.session_state.get("fields_confirmed") | |
| and st.session_state.get("recommendation_result") | |
| and not st.session_state.get("analysis_running") | |
| ) | |
| return False | |
| def _proposal_state() -> str | None: | |
| """Where the stored proposal stands, in the Proposal screen's own terms. | |
| ``"ready"``: a draft the screen offers to send (fact-checked, or marked | |
| as not fact-checked when the check could not run). ``"review"``: the | |
| fact-check ran and flagged claims, so the screen asks for a closer look | |
| first. ``"retry"``: nothing to send β the check left no text, or the AI | |
| did not write it and only the fixed outline is there. ``None`` before any | |
| result. The rail tick and the header pill both read this, so neither | |
| calls a proposal ready while the screen is saying it is not. | |
| """ | |
| vp = st.session_state.get("verified_proposal") | |
| if not isinstance(vp, dict) or not vp: | |
| return None | |
| written_by_ai = bool((vp.get("__meta__") or {}).get("used_api")) | |
| if not written_by_ai or not str(vp.get("proposal") or "").strip(): | |
| return "retry" | |
| if vp.get("verification_status") == "failed": | |
| return "review" | |
| return "ready" | |
| def _is_completed(step_key: str) -> bool: | |
| if step_key == "setup": | |
| return bool(st.session_state.get("api_ok")) | |
| if step_key == "dossier": | |
| return bool(st.session_state.get("evidence_index")) | |
| if step_key == "interview": | |
| return bool( | |
| st.session_state.get("evidence_index") | |
| and st.session_state.get("profile_source") == "interview" | |
| ) | |
| if step_key == "screenshot": | |
| return bool(st.session_state.get("extracted_job_fields")) | |
| if step_key == "confirmation": | |
| return bool(st.session_state.get("fields_confirmed")) | |
| if step_key == "analysis": | |
| return bool(st.session_state.get("recommendation_result")) | |
| if step_key == "proposal": | |
| return _proposal_state() == "ready" | |
| return False | |
| def _lock_reason(step_key: str, show_debug: bool = False) -> str: | |
| if step_key in ("dossier", "interview"): | |
| return "Locked β run the API check first." | |
| if step_key == "screenshot": | |
| return "Locked β read your dossier and build proof points first." | |
| if step_key == "confirmation": | |
| return "Locked β extract job details from a screenshot first." | |
| if step_key == "analysis": | |
| if show_debug: | |
| return "Locked β confirm the job details first." | |
| # Confirm Details is hidden outside debug mode: the details are | |
| # confirmed when the user presses Analyze Opportunity. | |
| return "Locked β add a job screenshot and press Analyze Opportunity first." | |
| if step_key == "proposal": | |
| if st.session_state.get("analysis_running"): | |
| return "Locked β wait for the analysis to finish." | |
| return "Locked β run the analysis first." | |
| return "Locked." | |
| def _step_status(step_key: str, current: str) -> str: | |
| if step_key == current: | |
| return "current" | |
| if not _is_unlocked(step_key): | |
| return "locked" | |
| if _is_completed(step_key): | |
| return "completed" | |
| return "available" | |
| # Quiet glyphs that read as a clean status, not technical noise. | |
| _STATUS_GLYPH = { | |
| "completed": "β", | |
| "current": "β", | |
| "available": "β", | |
| "locked": "β¦", | |
| } | |
| # Session status pill -> the design canvas's pill tones. | |
| _CHIP_TONE = {"ready": "ok", "info": "violet", "attention": "warn", "missing": "stop"} | |
| # The pill for a stored proposal, by _proposal_state(). | |
| _PROPOSAL_CHIP = { | |
| "ready": ("Proposal Ready", "ready"), | |
| "review": ("Proposal Needs Review", "attention"), | |
| "retry": ("Proposal Needs a Retry", "attention"), | |
| } | |
| def _header_chip() -> tuple[str, str]: | |
| proposal = _proposal_state() | |
| if proposal: | |
| return _PROPOSAL_CHIP[proposal] | |
| if st.session_state.get("recommendation_result"): | |
| return "Analysis Ready", "info" | |
| if st.session_state.get("api_ok"): | |
| return "API Ready", "ready" | |
| return "API Missing", "missing" | |
| def _render_sidebar(show_debug: bool) -> None: | |
| # The rail from the design canvas: brand lockup, a caption, one row per | |
| # step, and the engine card pinned to the foot. The rows are still real | |
| # st.button controls β the widget layer gives each one its step icon and | |
| # its on / done / locked state. | |
| theme.sidebar_brand(APP_TITLE, APP_TAGLINE) | |
| theme.rail_cap("Workflow") | |
| current = st.session_state.current_step | |
| # A sub-screen lights its parent's row, so the rail always shows where the | |
| # user is in the workflow β but the row still navigates, because from the | |
| # interview "Dossier" means "take me back to the folder route". | |
| rail_current = _parent_step(current) | |
| for key, label in _visible_steps(show_debug): | |
| status = _step_status(key, rail_current) | |
| unlocked = status != "locked" | |
| glyph = _STATUS_GLYPH.get(status, "β") | |
| # The ordinal moved to the top bar, which now shows the live segmented | |
| # rail and "Step N of M". The glyph stays: it is the only per-row state | |
| # signal Streamlit does not already express through type= / disabled. | |
| btn_label = f"{glyph} {label}" | |
| clicked = st.button( | |
| btn_label, | |
| key=f"nav_btn_{key}", | |
| type="primary" if status == "current" else "secondary", | |
| disabled=not unlocked, | |
| use_container_width=True, | |
| help=None if unlocked else _lock_reason(key, show_debug), | |
| ) | |
| if clicked and unlocked and key != current: | |
| st.session_state.current_step = key | |
| st.rerun() | |
| # API status warning β shown any time the key is no longer working so the | |
| # user knows why things are failing without hunting through the screens. | |
| if st.session_state.get("api_ok") is False and st.session_state.get("api_status") not in (None,): | |
| st.warning("AI service issue β go to Setup to fix it.") | |
| # ββ Always-visible settings link βββββββββββββββββββββββββββββββββββ | |
| # The Setup screen is auto-skipped once an API key is saved, so users | |
| # need an explicit way back to change it. | |
| st.divider() | |
| if st.button( | |
| "Change API Settings", | |
| key="goto_setup_btn", | |
| use_container_width=True, | |
| type="secondary", | |
| help="Go back to Setup to change your API key or AI provider.", | |
| ): | |
| st.session_state.current_step = "setup" | |
| st.rerun() | |
| # Dark / light theme toggle. | |
| dark_on = st.toggle( | |
| "Dark mode", | |
| value=bool(st.session_state.get("theme_dark", True)), | |
| key="theme_toggle", | |
| ) | |
| if dark_on != st.session_state.get("theme_dark"): | |
| st.session_state.theme_dark = dark_on | |
| st.rerun() | |
| # Problem/diagnostics panel. NOT debug-gated: a user who hits an error is | |
| # precisely the user who cannot flip SHOW_DEBUG_PANEL, so the explanation | |
| # and the share button have to be reachable by default. Renders nothing | |
| # when there are no failures, leaving a healthy run visually unchanged. | |
| diagnostics_panel.render(key_suffix="sidebar") | |
| # Developer-only API usage panel. Hidden unless SHOW_DEBUG_PANEL=true. | |
| api_usage_panel.render(key_suffix="sidebar") | |
| # The engine card closes the rail, as on the design canvas. The provider | |
| # and model names are debug-gated exactly as everywhere else in this app | |
| # (Settings.show_debug_panel), so a normal user sees the state of their AI | |
| # service and nothing about which one it is. | |
| _render_engine_card(show_debug) | |
| def _render_engine_card(show_debug: bool) -> None: | |
| settings = get_settings() | |
| ok = bool(st.session_state.get("api_ok")) | |
| if show_debug: | |
| theme.engine_card( | |
| "Engine", | |
| settings.active_model or "Not configured", | |
| f"{settings.active_provider or 'no provider'} Β· " | |
| + ("connected" if ok else "not connected"), | |
| "ok" if ok else "stop", | |
| ) | |
| return | |
| theme.engine_card( | |
| "AI service", | |
| "Connected" if ok else "Not connected", | |
| "Ready for analysis" if ok else "Set your key on Setup", | |
| "ok" if ok else "warn", | |
| ) | |
| def _try_restore_saved_config() -> None: | |
| """Load this browser's saved (encrypted) Setup config from Supabase. | |
| Runs once per session. Only acts when key storage is configured and the | |
| session has no API key yet. On success it applies the saved provider / | |
| model / key / vision settings into the session so the rest of the app β | |
| including smart-landing's API check β proceeds as if the user just set up. | |
| Entirely fail-safe (see :mod:`app.services.key_store`): any failure leaves | |
| the session untouched and the user simply lands on Setup as before. | |
| """ | |
| ss = st.session_state | |
| if ss.get("_restore_done"): | |
| return | |
| if not key_store.is_configured(): | |
| ss["_restore_done"] = True | |
| return | |
| if get_settings().has_api_key: | |
| ss["_restore_done"] = True | |
| return | |
| tag = browser_tag.get_or_create_tag() | |
| if not tag: | |
| return # tag not available yet; try again on the next render | |
| # A Clear Credentials whose delete failed is finished first: the user | |
| # asked for this browser's saved key to be forgotten, so nothing is | |
| # restored while that delete is still pending. | |
| if setup_screen.finish_pending_forget(tag): | |
| ss["_restore_done"] = True | |
| return | |
| stored = key_store.load_config(tag) | |
| ss["_restore_done"] = True | |
| # Provider, model and key choices only. A config saved by an older version | |
| # also holds operator-only settings such as SHOW_DEBUG_PANEL, which must | |
| # keep following the server rather than what it was at save time. | |
| stored = { | |
| k: v for k, v in (stored or {}).items() if k in REMEMBERED_CONFIG_KEYS | |
| } | |
| if stored: | |
| apply_session_overrides(stored) | |
| ss["_config_restored"] = True | |
| def _try_smart_landing(settings) -> None: | |
| """On a fresh session, silently restore Setup + Dossier if already done. | |
| Setup: re-run the API check. If it passes, mark api_ok = True. | |
| Dossier: if a folder path was previously entered AND is still valid, | |
| silently re-load the evidence index. If the path is gone/broken, show | |
| a sidebar warning instead of forcing the user back to the Dossier screen. | |
| On success both steps are skipped and the user lands on Job Screenshot. | |
| On any failure the user lands on the broken step as normal. | |
| """ | |
| ss = st.session_state | |
| # --- Step 1: API check ------------------------------------------- | |
| # Only attempt if a key is already saved; don't block if it's missing. | |
| if not ss.get("api_ok") and settings.has_api_key: | |
| try: | |
| result = run_capability_test(settings=settings, live=True) | |
| if result.ok: | |
| ss["api_ok"] = True | |
| ss["api_status"] = ApiGateError.API_OK | |
| else: | |
| ss["api_ok"] = False | |
| ss["api_status"] = result.status | |
| # Can't go further without a working API key. | |
| ss["current_step"] = "setup" | |
| return | |
| except Exception: | |
| ss["api_ok"] = False | |
| ss["current_step"] = "setup" | |
| return | |
| if not ss.get("api_ok"): | |
| # No key saved at all β stay on Setup. | |
| ss["current_step"] = "setup" | |
| return | |
| # --- Step 2: Dossier auto-load ----------------------------------- | |
| if ss.get("evidence_index"): | |
| # Evidence index already loaded in this session β skip to screenshot. | |
| ss["current_step"] = "screenshot" | |
| return | |
| # Prefer the session-state path; fall back to the persisted .env path. | |
| path = (ss.get("dossier_folder_path") or "").strip() | |
| if not path: | |
| path = get_saved_dossier_path() | |
| if not path: | |
| # No path saved anywhere β go to Dossier so user can enter one. | |
| ss["current_step"] = "dossier" | |
| return | |
| # Always keep session_state in sync so the dossier screen shows it. | |
| ss["dossier_folder_path"] = path | |
| try: | |
| result = validate(path) | |
| if not result.can_continue: | |
| # Path exists but unusable β land on Dossier with a clear error. | |
| ss["dossier_validation"] = result | |
| ss["current_step"] = "dossier" | |
| return | |
| chunks = read_dossier(path) | |
| if not chunks: | |
| ss["current_step"] = "dossier" | |
| return | |
| proofs, profile, meta = build_evidence_index(chunks) | |
| ss["dossier_chunks"] = chunks | |
| ss["dossier_read"] = True | |
| ss["dossier_read_error"] = False | |
| # Re-scored from what the files hold, as the Dossier screen does: | |
| # validate() sees file names only, so a full dossier PDF scored thin. | |
| ss["dossier_validation"] = score_evidence(result, proofs) if proofs else result | |
| ss["evidence_index"] = proofs | |
| ss["canonical_profile"] = profile | |
| ss["evidence_index_meta"] = meta | |
| ss["current_step"] = "screenshot" | |
| except Exception: | |
| # Folder moved or deleted β land on Dossier, warn there. | |
| ss["dossier_read_error"] = True | |
| ss["current_step"] = "dossier" | |
| # Shown in place of a screen that raised. The details go to the server log. | |
| _SCREEN_ERROR_MESSAGE = ( | |
| "Something went wrong in the app on this step. Your work so far is kept: " | |
| "try again, or go back a step in the sidebar." | |
| ) | |
| def _report_screen_crash(step: str, exc: Exception) -> None: | |
| """Log a screen's uncaught exception and record it for diagnostics. | |
| Call it in the ``except``. The traceback goes to the server log, inside | |
| the message so the log's redaction filter scrubs it like any other line. | |
| The diagnostics event names the exception's class only β never its | |
| message, which can quote dossier or job text. | |
| """ | |
| error_type = type(exc).__name__ | |
| _logger.error( | |
| "screen=%s status=app_error error_type=%s\n%s", | |
| step, | |
| error_type, | |
| traceback.format_exc(), | |
| ) | |
| # Same record shape as llm_client's, so a report renders one table. | |
| diagnostics.record_event( | |
| { | |
| "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "task_name": f"{step}_screen", | |
| "provider": None, | |
| "model": None, | |
| "used_api": False, | |
| "status": "app_error", | |
| "error_message": None, | |
| "duration_seconds": None, | |
| "estimated_input_tokens": None, | |
| "estimated_output_tokens": None, | |
| "request_id": None, | |
| "http_status": None, | |
| "error_type": error_type, | |
| } | |
| ) | |
| def main() -> None: | |
| st.set_page_config( | |
| page_title=APP_TITLE, | |
| page_icon="π§", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| theme.inject_css() | |
| _init_session_state() | |
| settings = get_settings() | |
| show_debug = bool(getattr(settings, "show_debug_panel", False)) | |
| # Confirm Details is debug-only β keep normal users out of it even if a | |
| # stale navigation target points there. | |
| if not show_debug and st.session_state.current_step == "confirmation": | |
| st.session_state.current_step = ( | |
| "analysis" if st.session_state.get("fields_confirmed") else "screenshot" | |
| ) | |
| # ---- Smart landing: skip screens the user has already completed -------- | |
| # On a fresh Streamlit session (first page load or page refresh) we check | |
| # whether Setup and Dossier are already done and land the user directly on | |
| # Job Screenshot so they never have to re-enter their key or folder path. | |
| # We only do this once per session (guarded by the _smart_land_done flag), | |
| # and only when the session default "setup" is still the current step. | |
| # Restore a previously saved (encrypted) config for this browser BEFORE | |
| # smart-landing, so a returning visitor's key is in place and they get | |
| # auto-advanced past Setup without re-entering anything. | |
| _try_restore_saved_config() | |
| settings = get_settings() | |
| if not st.session_state.get("_smart_land_done"): | |
| st.session_state["_smart_land_done"] = True | |
| _try_smart_landing(settings) | |
| if not _is_unlocked(st.session_state.current_step): | |
| st.session_state.current_step = "setup" | |
| # Re-tint the whole screen to the current step's signature color. A | |
| # sub-screen borrows its parent's accent, breadcrumb and step number. | |
| dark = bool(st.session_state.get("theme_dark", True)) | |
| current_step = st.session_state.current_step | |
| crumb_step = _parent_step(current_step) | |
| theme.apply_step_accent(crumb_step, dark=dark) | |
| # The top bar from the design canvas: breadcrumb, a segmented rail that | |
| # lights the step you are on, "Step N of M", and the session status pill. | |
| visible = _visible_steps(show_debug) | |
| step_index = None | |
| for idx, (key, _label) in enumerate(visible, start=1): | |
| if key == crumb_step: | |
| step_index = idx | |
| break | |
| chip_label, chip_kind = _header_chip() | |
| theme.top_bar( | |
| crumb_a=APP_TITLE, | |
| crumb_b=dict(STEPS).get(crumb_step, crumb_step), | |
| step_index=step_index, | |
| step_total=len(visible), | |
| right_html=theme.tag(chip_label, _CHIP_TONE.get(chip_kind, "")), | |
| ) | |
| with st.sidebar: | |
| _render_sidebar(show_debug) | |
| # Dispatched through .get: an unknown step key (stale session state, or a | |
| # screen that navigated somewhere that no longer exists) would otherwise | |
| # raise a KeyError and blank the whole app. | |
| renderer = RENDERERS.get(st.session_state.current_step) | |
| if renderer is None: | |
| st.session_state.current_step = "setup" | |
| renderer = RENDERERS["setup"] | |
| # A screen that raises gets a plain message in its place instead of | |
| # Streamlit's traceback, which shows server paths and source lines to the | |
| # visitor. The rail and the session are untouched, so they can try again. | |
| # Exception only: st.rerun() and st.stop() raise BaseException subclasses, | |
| # which must keep passing through. | |
| step = st.session_state.current_step | |
| try: | |
| renderer() | |
| except Exception as exc: # noqa: BLE001 - last guard around a whole screen | |
| _report_screen_crash(step, exc) | |
| st.error(_SCREEN_ERROR_MESSAGE) | |
| # Motion drives the animation the CSS layer cannot express: panels that | |
| # arrive as they scroll into view, springs on hover and press, the fit ring | |
| # drawing to its score, and numbers counting up to the value just computed. | |
| # Started last so its first pass sees a fully rendered page; after that a | |
| # MutationObserver keeps up with Streamlit's reruns on its own. | |
| motion_runtime.inject() | |
| if __name__ == "__main__": | |
| main() | |