Spaces:
Sleeping
Sleeping
File size: 25,620 Bytes
6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 93411b2 6303ae6 22b38d8 357ee36 6303ae6 93411b2 6303ae6 93411b2 6303ae6 c9d9189 6303ae6 93411b2 6303ae6 357ee36 6303ae6 93411b2 6303ae6 357ee36 6303ae6 357ee36 6303ae6 357ee36 6303ae6 93411b2 6303ae6 357ee36 6303ae6 357ee36 93411b2 6303ae6 357ee36 6303ae6 357ee36 6303ae6 c9d9189 6303ae6 c9d9189 357ee36 c9d9189 6303ae6 357ee36 6303ae6 c9d9189 6303ae6 93411b2 6303ae6 c9d9189 93411b2 6303ae6 c9d9189 6303ae6 357ee36 6303ae6 93411b2 6303ae6 c9d9189 6303ae6 c9d9189 6303ae6 c9d9189 6303ae6 22b38d8 6303ae6 c9d9189 6303ae6 be469a4 357ee36 be469a4 357ee36 be469a4 6303ae6 357ee36 6303ae6 357ee36 6303ae6 be469a4 6303ae6 93411b2 c9d9189 6303ae6 93411b2 6303ae6 c9d9189 6303ae6 c9d9189 6303ae6 93411b2 c9d9189 6303ae6 c9d9189 93411b2 c9d9189 6303ae6 93411b2 357ee36 6303ae6 c9d9189 6303ae6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 | """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()
|